From bfed33fe4549889e64055240a585c8aa49da9fdf Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 17:28:47 +0200 Subject: [PATCH 01/84] Fixed: the source sweep inside hidden folders and the deleted-files cases --- .gitignore | 1 + docs/development/application/browser.md | 2 +- docs/development/bugs-and-todos.md | 5 ---- .../checks/source/modules.py | 7 +++-- .../application/restart/test_deleted_files.py | 29 +++++++------------ .../checks/source/test_modules.py | 6 ++++ 6 files changed, 22 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index b35b53e34..f9ba5ecf8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ .venv/ .venv-build/ .vscode/ +.worktrees/ dist/ wheels/ diff --git a/docs/development/application/browser.md b/docs/development/application/browser.md index f93b8b6dc..0f0a2f37b 100644 --- a/docs/development/application/browser.md +++ b/docs/development/application/browser.md @@ -44,7 +44,7 @@ Which row carries the configuration follows the branch. In the configuration bra **A row naming a reconstruction says what made it and what it is made of.** The configuration comes from a directory name. A row carries it where the configuration is what distinguishes that row, and reads it from the directory above otherwise, so both branches say the same thing about one file. The recordings come from the document, which is the only place that knows them. A row asks for them as the pointer reaches it, and the answer is kept against the moment the file was last written. One reading answers for both tabs. A pointer crossing many rows leaves the reading of the row it comes to rest on, and an answer landing after the details were built rebuilds them where they stand. Each recording is listed under the color the id it was converted as gives it, the color [the stems card](stems.md) paints it in. A document naming one recording says what the row already says, so the list is left to the documents holding several. -**Favorites are paths.** A row is a favorite when its path is in the session's set, and it reads as part of a favorite folder when any of its parents is. A reconstruction therefore reads as part of a favorite folder wherever a view puts it, including the sample branch, whose headings carry no path. One path reaches the panel as several rows, so `application.py` resolves the toggled path into every row representing it and hands them to both tabs. Each row repaints with the ancestry its own path carries. +**Favorites are paths.** A row is a favorite when its path is in the session's set, and it reads as part of a favorite folder when any of its parents is. A reconstruction therefore reads as part of a favorite folder wherever a view puts it, including the sample branch, whose headings carry no path. One path reaches the panel as several rows, so `application.py` resolves the toggled path into every row representing it and hands them to both tabs. Each row repaints with the ancestry its own path carries. A starred path the disk no longer holds stays starred, so a file on a drive unplugged for one run carries its star once the drive is back. ## Filtering diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index a4595abdc..4d8a0dcae 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -160,11 +160,6 @@ currently out of line. An entry leaves when the code meets the contract again. 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/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/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/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. diff --git a/src/sampletones_tools/checks/source/modules.py b/src/sampletones_tools/checks/source/modules.py index bdb2a23ac..27828b2cf 100644 --- a/src/sampletones_tools/checks/source/modules.py +++ b/src/sampletones_tools/checks/source/modules.py @@ -77,8 +77,9 @@ def is_visible(path: Path) -> bool: def source_paths(roots: Iterable[Path]) -> List[Path]: """Every Python file under the given roots, in path order. - The sweep visits visible paths, so a virtual environment or a tooling cache sitting inside a - root stays aside from a whole-repository run. A check built on a sweep that reads nothing + The sweep visits the paths visible below each root, so a virtual environment or a tooling cache + sitting inside a root stays aside from a whole-repository run, while a root standing inside a + hidden folder, such as a worktree, is swept whole. A check built on a sweep that reads nothing reports nothing, which reads as a clean tree, so each root must name a directory and the roots together must hold source to read. @@ -98,7 +99,7 @@ def source_paths(roots: Iterable[Path]) -> List[Path]: if not root.is_dir(): raise NotADirectoryError(f"The source root {root} names no directory to sweep") - found = {path for root in directories for path in root.rglob(SOURCE_PATTERN) if is_visible(path)} + found = {path for root in directories for path in root.rglob(SOURCE_PATTERN) if is_visible(path.relative_to(root))} if not found: listed = ", ".join(str(root) for root in directories) raise FileNotFoundError(f"The source roots hold no {SOURCE_PATTERN} file to read: {listed}") diff --git a/tests/screens/application/restart/test_deleted_files.py b/tests/screens/application/restart/test_deleted_files.py index 10b25350b..19c62aa11 100644 --- a/tests/screens/application/restart/test_deleted_files.py +++ b/tests/screens/application/restart/test_deleted_files.py @@ -39,8 +39,9 @@ def deleted_favorite() -> Path: 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. + The application starts quietly with nothing restored, and a dialog opens in the nearest folder + still standing. Leaving lets go of the reconstruction that failed to open and keeps the folder and + the starred file, so a drive plugged back in is found where it was left. """ @pytest.fixture @@ -77,30 +78,20 @@ def test_it_restores_nothing_and_asks_in_the_nearest_standing_folder(self, scree 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.""" + def test_the_session_left_lets_the_reconstruction_go_and_keeps_the_folder(self, screen: Screen) -> None: + """The session written at exit holds no reconstruction and still names the deleted 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.""" + assert state.last_paths.reconstruction == deleted_folder() + + def test_the_settings_left_still_star_the_deleted_file(self, screen: Screen) -> None: + """The settings written at exit keep the deleted file among 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 + assert deleted_favorite() in written_application_config().favorites.paths diff --git a/tests/unit/sampletones_tools/checks/source/test_modules.py b/tests/unit/sampletones_tools/checks/source/test_modules.py index bd5cfa7f8..6c39c0fc4 100644 --- a/tests/unit/sampletones_tools/checks/source/test_modules.py +++ b/tests/unit/sampletones_tools/checks/source/test_modules.py @@ -91,6 +91,12 @@ def test_a_hidden_directory_stays_aside(self, tmp_path: Path) -> None: write_module(tmp_path / ".cache", "cached.py", MODULE_BODY) assert source_paths([tmp_path]) == [visible] + def test_a_root_inside_a_hidden_folder_is_swept(self, tmp_path: Path) -> None: + """A worktree kept in a hidden folder is a tree to read, whatever the folders above it are named.""" + root = tmp_path / ".worktrees" / "branch" + path = write_module(root / "package", "first.py", MODULE_BODY) + assert source_paths([root]) == [path] + def test_a_file_of_another_kind_stays_aside(self, tmp_path: Path) -> None: visible = write_module(tmp_path, "first.py", MODULE_BODY) write_module(tmp_path, "notes.md", "# notes\n") From a9c723d266a16d76b6e245eff548b8b846439b62 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 17:46:44 +0200 Subject: [PATCH 02/84] Fixed: note keys taking combinations meant for the shortcuts --- docs/development/application/keyboard.md | 2 +- docs/development/bugs-and-todos.md | 4 -- .../reconstruction/instruments/instruments.py | 10 ++-- .../ui/panels/sequencer/order/panel.py | 6 +-- .../ui/panels/sequencer/tracker/panel.py | 10 ++-- .../utils/gui/keyboard/event.py | 10 ++++ .../utils/gui/keyboard/modifiers.py | 1 + .../utils/gui/keyboard/piano.py | 22 ++++++++- .../instruments/test_note_keys.py | 5 -- .../reconstruction/test_instruments_panel.py | 28 +++++++++++ .../ui/panels/sequencer/test_order_keys.py | 9 +++- .../sequencer/test_tracker_navigation.py | 17 ++++++- .../ui/panels/sequencer/test_tracker_piano.py | 17 +++++++ .../utils/gui/keyboard/test_event.py | 41 +++++++++++++++- .../utils/gui/keyboard/test_piano.py | 48 +++++++++++++++++++ 15 files changed, 203 insertions(+), 27 deletions(-) create mode 100644 tests/unit/sampletones_application/utils/gui/keyboard/test_piano.py diff --git a/docs/development/application/keyboard.md b/docs/development/application/keyboard.md index 87bd5e1a8..96f9d7957 100644 --- a/docs/development/application/keyboard.md +++ b/docs/development/application/keyboard.md @@ -24,7 +24,7 @@ The application orders its scopes by priority, from highest to lowest: | `PANEL` | a sub-panel the keys are meant for | its tab is in front, its card is open, and the sub-panel holds what the keys act on: a cursor, a row picked out, or an open audition | handles the keys its own category names and yields every combination it does not own, so a higher-reaching shortcut still wins | | `SHORTCUT` | application shortcuts (`ShortcutManager`) | always | fires the matching shortcut while no field is being edited, or whenever the shortcut is `field_transparent` | -The router offers a panel the key ahead of the shortcut scope. A panel therefore returns `False` on any combination it does not own. The grid, for example, yields every `Ctrl`-modified press. That lets field-transparent shortcuts, such as the tab switch, reach the shortcut scope while a grid cursor is set. +The router offers a panel the key ahead of the shortcut scope. A panel therefore returns `False` on any combination it does not own. A panel that reads keys as entry, such as notes or hex digits, takes **plain presses** alone: a key pressed on its own or with Shift, the way a character is typed. Ctrl, Alt and Super make a press a command, and a command goes on to the shortcuts. The grid therefore lets `Ctrl+D` through, and an open instrument's note keys let `Ctrl+Z` through. That lets field-transparent shortcuts, such as the tab switch, reach the shortcut scope while a grid cursor is set. ### A panel scope answers on its own tab, from an open card diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 4d8a0dcae..aa90ad860 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -194,10 +194,6 @@ currently out of line. An entry leaves when the code meets the contract again. * 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/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/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/tracker/test_notes_typed.py` reproduces it as a known failure. diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 969227ae9..b75d866c0 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -81,7 +81,7 @@ KeyRouter, panel_scope_active, ) -from sampletones_application.utils.gui.keyboard.piano import PIANO_KEYS +from sampletones_application.utils.gui.keyboard.piano import semitone_of from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.view_model.reconstruction.envelopes import ( @@ -603,8 +603,12 @@ def _audition_keys_active(self) -> bool: ) def _on_key_pressed(self, event: KeyEvent) -> bool: - """Sounds the open instrument at the note a piano key names, reporting whether it did.""" - semitone = PIANO_KEYS.get(event.key) + """Sounds the open instrument at the note a plain piano key names, reporting whether it did. + + A combination ending in a note key passes on to the shortcuts, so Undo and Save answer + while an instrument is open. + """ + semitone = semitone_of(event) if semitone is None: return False diff --git a/src/sampletones_application/ui/panels/sequencer/order/panel.py b/src/sampletones_application/ui/panels/sequencer/order/panel.py index 3c19f608d..70050ffad 100644 --- a/src/sampletones_application/ui/panels/sequencer/order/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/order/panel.py @@ -1329,10 +1329,10 @@ def commit_entry(self) -> None: def _type_character(self, event: KeyEvent) -> bool: """Types a hex digit into the cell under the cursor, reporting whether the press was one. - A press holding Ctrl or Alt is an application gesture, so cell entry reads the plain keys - and leaves the rest to the global shortcuts. + A press holding Ctrl, Alt or Super is an application gesture, so cell entry reads the plain + keys and leaves the rest to the global shortcuts. """ - if Modifier.CTRL in event.modifiers or Modifier.ALT in event.modifiers: + if not event.is_plain: return False char = HEX_KEYS.get(event.key) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py index 20aae01dc..4318fbb7d 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py @@ -114,7 +114,7 @@ Modifier, capture_modifiers, ) -from sampletones_application.utils.gui.keyboard.piano import PIANO_KEYS +from sampletones_application.utils.gui.keyboard.piano import semitone_of from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.utils.gui.tooltip import show_tooltip @@ -1773,10 +1773,10 @@ def _type_character(self, event: KeyEvent) -> bool: """Types a note, digit or sign into the cell under the cursor, reporting whether the press was one. - A press holding Ctrl or Alt is an application gesture, so cell entry reads the plain keys - and leaves the rest to the global shortcuts. + A press holding Ctrl, Alt or Super is an application gesture, so cell entry reads the plain + keys and leaves the rest to the global shortcuts. """ - if Modifier.CTRL in event.modifiers or Modifier.ALT in event.modifiers: + if not event.is_plain: return False if self._type_note(event): @@ -1808,7 +1808,7 @@ def _type_note(self, event: KeyEvent) -> bool: if speaks_in_periods(cursor.channel): return False - semitone = PIANO_KEYS.get(event.key) + semitone = semitone_of(event) if semitone is None: return False diff --git a/src/sampletones_application/utils/gui/keyboard/event.py b/src/sampletones_application/utils/gui/keyboard/event.py index 3383d791e..07ade4db2 100644 --- a/src/sampletones_application/utils/gui/keyboard/event.py +++ b/src/sampletones_application/utils/gui/keyboard/event.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from sampletones_application.utils.gui.keyboard.modifiers import ( + TYPING_MODIFIERS, ModifierSet, capture_modifiers, ) @@ -18,6 +19,15 @@ class KeyEvent: key: int modifiers: ModifierSet + @property + def is_plain(self) -> bool: + """Whether the press types its key, held alone or with Shift as a character is typed. + + Ctrl, Alt and Super make a press a command, which the shortcuts answer. A panel that reads + keys as entry, such as a note or a hex digit, takes plain presses and yields the rest. + """ + return self.modifiers <= TYPING_MODIFIERS + @classmethod def capture(cls, key: int) -> KeyEvent: """Builds an event for ``key`` with the modifiers currently held.""" diff --git a/src/sampletones_application/utils/gui/keyboard/modifiers.py b/src/sampletones_application/utils/gui/keyboard/modifiers.py index 7110d56a8..4eeae2139 100644 --- a/src/sampletones_application/utils/gui/keyboard/modifiers.py +++ b/src/sampletones_application/utils/gui/keyboard/modifiers.py @@ -33,6 +33,7 @@ class Modifier(StrEnum): CTRL_ALT: Final[ModifierSet] = frozenset({Modifier.CTRL, Modifier.ALT}) CTRL_SHIFT: Final[ModifierSet] = frozenset({Modifier.CTRL, Modifier.SHIFT}) CTRL_ALT_SHIFT: Final[ModifierSet] = frozenset({Modifier.CTRL, Modifier.ALT, Modifier.SHIFT}) +TYPING_MODIFIERS: Final[ModifierSet] = SHIFT MODIFIER_NAMES: Final[Dict[str, Modifier]] = { "ctrl": Modifier.CTRL, diff --git a/src/sampletones_application/utils/gui/keyboard/piano.py b/src/sampletones_application/utils/gui/keyboard/piano.py index 8b5afc979..e60788811 100644 --- a/src/sampletones_application/utils/gui/keyboard/piano.py +++ b/src/sampletones_application/utils/gui/keyboard/piano.py @@ -1,7 +1,8 @@ -from typing import Dict, Final, Tuple +from typing import Dict, Final, Optional, Tuple import dearpygui.dearpygui as dpg +from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_shared.constants.music import OCTAVE_SEMITONES _LOWER_ROW: Final[Tuple[int, ...]] = ( @@ -44,3 +45,22 @@ bottom row opens at the octave in force and the top row an octave above it, with the black keys on the row over each. """ + + +def semitone_of(event: KeyEvent) -> Optional[int]: + """The note a plain press of a piano key names, as semitones above the C being typed at. + + A note key held with Ctrl, Alt or Super is a combination, which belongs to the shortcuts, so + Ctrl+Z undoes wherever the Z key plays a C. + + Args: + event: The press, carrying the modifiers held as it fired. + + Returns: + Optional[int]: The semitones the key stands above the C, or ``None`` for a press that + names no note. + """ + if not event.is_plain: + return None + + return PIANO_KEYS.get(event.key) diff --git a/tests/screens/reconstructions/instruments/test_note_keys.py b/tests/screens/reconstructions/instruments/test_note_keys.py index c5916f8e3..fac534dfb 100644 --- a/tests/screens/reconstructions/instruments/test_note_keys.py +++ b/tests/screens/reconstructions/instruments/test_note_keys.py @@ -59,11 +59,6 @@ def a_field_takes_them_as_characters(screen: Screen) -> None: 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 diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index af3f7acfc..cc7135b7b 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -32,6 +32,8 @@ from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.gui.keyboard import KeyEvent, KeyRouter +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.modifiers import SHIFT from sampletones_application.utils.gui.keyboard.piano import PIANO_KEYS from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource @@ -814,6 +816,32 @@ def test_a_key_naming_no_note_is_left_to_the_shortcuts( assert panel._on_key_pressed(KeyEvent(key=dpg.mvKey_Spacebar, modifiers=frozenset())) is False assert sounded == [] + @pytest.mark.parametrize("typed", ["Ctrl+Z", "Ctrl+S", "Ctrl+Shift+Z", "Alt+Z", "Super+Z"]) + def test_a_combination_ending_in_a_note_key_is_left_to_the_shortcuts( + self, + panel: GUIReconstructionInstrumentsPanel, + typed: str, + ) -> None: + """Undo, Save and the rest reach their shortcuts while an instrument is open.""" + sounded: List[int] = [] + panel.on_audition_requested = sounded.append + panel.update_view(ONE_INSTRUMENT) + combination = KeyCombination.parse(typed) + + assert panel._on_key_pressed(KeyEvent(key=combination.key, modifiers=combination.modifiers)) is False + assert sounded == [] + + def test_a_note_key_under_shift_sounds_its_note( + self, + panel: GUIReconstructionInstrumentsPanel, + ) -> None: + sounded: List[int] = [] + panel.on_audition_requested = sounded.append + panel.update_view(ONE_INSTRUMENT) + + assert panel._on_key_pressed(KeyEvent(key=dpg.mvKey_Z, modifiers=SHIFT)) is True + assert sounded == [PIANO_KEYS[dpg.mvKey_Z]] + def test_the_keys_answer_while_an_instrument_is_open( self, panel: GUIReconstructionInstrumentsPanel, diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py index 1009cc7c6..b95cce1bb 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py @@ -135,11 +135,16 @@ def test_a_hex_key_types_into_the_cell_under_the_cursor(self, order: OrderPanelF assert order.panel._on_key_pressed(_press("A")) is True assert order.states[-1].pending == "A" - def test_a_modified_hex_key_reaches_the_application(self, order: OrderPanelFixture) -> None: + @pytest.mark.parametrize("written", ["Ctrl+D", "Alt+D", "Super+D"]) + def test_a_modified_hex_key_reaches_the_application(self, order: OrderPanelFixture, written: str) -> None: """Ctrl+D opens the display settings, so cell entry keeps the plain key alone.""" - assert order.panel._on_key_pressed(_press("Ctrl+D")) is False + assert order.panel._on_key_pressed(_press(written)) is False assert order.states == [] + def test_a_hex_key_under_shift_types_as_a_capital(self, order: OrderPanelFixture) -> None: + assert order.panel._on_key_pressed(_press("Shift+A")) is True + assert order.states[-1].pending == "A" + def test_the_clear_cell_key_empties_the_cell_and_moves_on(self, order: OrderPanelFixture) -> None: assert order.panel._on_key_pressed(_press("Del")) is True assert order.entries == [(CURSOR_POSITION, None)] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py index af1dfee49..9233552f9 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -339,11 +339,24 @@ def test_a_note_key_types_into_the_cell_under_the_cursor(self, monkeypatch: pyte assert panel._on_key_pressed(_press("C")) is True assert states[-1].pending == "C" - def test_a_modified_key_reaches_the_application(self, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize("written", ["Ctrl+D", "Alt+D", "Super+D"]) + def test_a_modified_key_reaches_the_application( + self, + monkeypatch: pytest.MonkeyPatch, + written: str, + ) -> None: """Ctrl+D opens the display settings, so cell entry keeps the plain hex key alone.""" panel = _panel() states: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) - assert panel._on_key_pressed(_press("Ctrl+D")) is False + assert panel._on_key_pressed(_press(written)) is False assert states == [] + + def test_a_key_under_shift_types_as_a_capital(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _panel() + states: List[TrackerInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + + assert panel._on_key_pressed(_press("Shift+C")) is True + assert states[-1].pending == "C" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py index 1001b6a3b..dbd6750d6 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_piano.py @@ -120,3 +120,20 @@ def test_a_key_no_note_stands_on_is_left_alone(self, monkeypatch: pytest.MonkeyP assert fixture.panel._type_note(_press("K")) is False assert fixture.typed == [] + + @pytest.mark.parametrize("written", ["Ctrl+Z", "Alt+Z", "Super+Z"]) + def test_a_combination_ending_in_a_note_key_is_left_to_the_shortcuts( + self, + monkeypatch: pytest.MonkeyPatch, + written: str, + ) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + + assert fixture.panel._type_note(_press(written)) is False + assert fixture.typed == [] + + def test_a_note_key_under_shift_types_its_note(self, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _panel(monkeypatch, ChannelName.PULSE1) + + assert fixture.panel._type_note(_press("Shift+Z")) is True + assert fixture.typed == [(ROW, ChannelName.PULSE1, _pitch(DEFAULT_OCTAVE, 0))] diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_event.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_event.py index 3c2d9db5d..86e5b3cdc 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_event.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_event.py @@ -1,7 +1,20 @@ +from dataclasses import dataclass from unittest.mock import patch +import pytest + from sampletones_application.utils.gui.keyboard.event import KeyEvent -from sampletones_application.utils.gui.keyboard.modifiers import CTRL_SHIFT +from sampletones_application.utils.gui.keyboard.modifiers import ( + ALT, + CTRL, + CTRL_SHIFT, + NO_MODIFIERS, + SHIFT, + SUPER, + ModifierSet, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase MODULE = "sampletones_application.utils.gui.keyboard.event" @@ -13,3 +26,29 @@ def test_capture_carries_the_key_and_the_modifiers_held_with_it() -> None: event = KeyEvent.capture(KEY) assert event == KeyEvent(key=KEY, modifiers=CTRL_SHIFT) + + +class TestIsPlain(BaseTestSuite): + """A press types its key alone or under Shift, and Ctrl, Alt or Super make it a command.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + modifiers: ModifierSet + expected: bool + + test_cases = ( + TestCase(label="no modifier", modifiers=NO_MODIFIERS, expected=True), + TestCase(label="shift, as a capital is typed", modifiers=SHIFT, expected=True), + TestCase(label="ctrl", modifiers=CTRL, expected=False), + TestCase(label="alt", modifiers=ALT, expected=False), + TestCase(label="super, the command key on macOS", modifiers=SUPER, expected=False), + TestCase(label="ctrl with shift", modifiers=CTRL_SHIFT, expected=False), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_is_plain(self, test_case: TestCase) -> None: + assert KeyEvent(key=KEY, modifiers=test_case.modifiers).is_plain is test_case.expected diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_piano.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_piano.py new file mode 100644 index 000000000..b9ab8cdca --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_piano.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass +from typing import Optional + +import pytest + +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.event import KeyEvent +from sampletones_application.utils.gui.keyboard.piano import semitone_of +from sampletones_shared.constants.music import OCTAVE_SEMITONES +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +def _press(text: str) -> KeyEvent: + """The press a written combination names, as the router delivers it.""" + combination = KeyCombination.parse(text) + return KeyEvent(key=combination.key, modifiers=combination.modifiers) + + +class TestSemitoneOf(BaseTestSuite): + """A plain note key names its note, and the same key in a combination names none.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + press: str + expected: Optional[int] + + test_cases = ( + TestCase(label="the C of the bottom row", press="Z", expected=0), + TestCase(label="a black key above it", press="S", expected=1), + TestCase(label="the C of the top row", press="Q", expected=OCTAVE_SEMITONES), + TestCase(label="a digit on the top row", press="2", expected=OCTAVE_SEMITONES + 1), + TestCase(label="a note key under shift", press="Shift+Z", expected=0), + TestCase(label="undo", press="Ctrl+Z", expected=None), + TestCase(label="save", press="Ctrl+S", expected=None), + TestCase(label="a note key under alt", press="Alt+Z", expected=None), + TestCase(label="undo on macOS", press="Super+Z", expected=None), + TestCase(label="redo", press="Ctrl+Shift+Z", expected=None), + TestCase(label="a key naming no note", press="K", expected=None), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_semitone_of(self, test_case: TestCase) -> None: + assert semitone_of(_press(test_case.press)) == test_case.expected From 0eb01d40258bba97a25924d2ce5438b211a0b791 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 17:48:03 +0200 Subject: [PATCH 03/84] Fixed: a project at an unreachable version refused by its version --- docs/development/bugs-and-todos.md | 5 -- docs/development/release/compatibility.md | 2 +- docs/formats/projects.md | 2 +- src/sampletones_core/compatibility/upgrade.py | 47 +++++++----- src/sampletones_core/data/metadata.py | 15 ++++ src/sampletones_core/project/container.py | 45 +++++------ src/sampletones_core/project/document.py | 11 ++- .../integration/compatibility/test_project.py | 7 +- .../old_files/test_broken_projects.py | 5 -- tests/suite/compatibility.py | 12 +-- .../compatibility/test_upgrade.py | 72 +++++++++++++++++- .../sampletones_core/data/test_metadata.py | 57 ++++++++++++++ .../project/test_container.py | 74 +++++++++++++++++++ 13 files changed, 283 insertions(+), 71 deletions(-) create mode 100644 tests/unit/sampletones_core/data/test_metadata.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 4d8a0dcae..f987c5557 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -155,11 +155,6 @@ 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/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/old_files/test_broken_projects.py` reproduce it as known failures. * 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. diff --git a/docs/development/release/compatibility.md b/docs/development/release/compatibility.md index 42521effa..d6c0c31b4 100644 --- a/docs/development/release/compatibility.md +++ b/docs/development/release/compatibility.md @@ -14,7 +14,7 @@ The upgrades live in `sampletones_core/compatibility` and run at the load bounda ### A format reads and writes one data version -Each format has one data version this build produces, held in `SAMPLETONES_LIBRARY_DATA_VERSION`, `SAMPLETONES_RECONSTRUCTION_DATA_VERSION` and `SAMPLETONES_PROJECT_DATA_VERSION` (`sampletones_shared/application.py`). The version travels inside every stored file, and the format's load contract holds each file to it: `MetadataContract` for the binary formats, and the `format_version` check for projects. +Each format has one data version this build produces, held in `SAMPLETONES_LIBRARY_DATA_VERSION`, `SAMPLETONES_RECONSTRUCTION_DATA_VERSION` and `SAMPLETONES_PROJECT_DATA_VERSION` (`sampletones_shared/application.py`). The version travels inside every stored file, and the format's load contract, a `MetadataContract`, holds each file to it before the rest of the file is read. A file at another version is therefore refused for its version, and the refusal names both versions. ### An upgrade is one version step diff --git a/docs/formats/projects.md b/docs/formats/projects.md index e4ac15330..f7b25a70a 100644 --- a/docs/formats/projects.md +++ b/docs/formats/projects.md @@ -65,7 +65,7 @@ path that means something only on the author's machine. `project.json` records the project format version it was written with. A file at the supported version loads as it stands. A file at an older version the upgrade chain reaches is migrated in memory to the -current shape first. Any other file is declined. [Data compatibility](../development/release/compatibility.md) +current shape first. Any other file is declined for its version, before the rest of it is read. [Data compatibility](../development/release/compatibility.md) describes the chain. Unknown or extra fields within a matching version are ignored, which leaves room for the format to grow. diff --git a/src/sampletones_core/compatibility/upgrade.py b/src/sampletones_core/compatibility/upgrade.py index ab05d8687..0d626b202 100644 --- a/src/sampletones_core/compatibility/upgrade.py +++ b/src/sampletones_core/compatibility/upgrade.py @@ -132,6 +132,33 @@ def upgrade_json(kind: ObjectKind, raw: bytes) -> bytes: return json.dumps(upgraded).encode("utf-8") +def read_version(kind: ObjectKind, payload: Any) -> Optional[str]: + """The data version a parsed payload states, read where its format keeps it. + + A load reads the version before the rest of the payload, so the format's contract can refuse a + version no upgrade reaches before any model reads the shape. + + Returns: + Optional[str]: The stated version, or ``None`` when the payload is not a mapping or states + no version as text. + """ + if not isinstance(payload, dict): + return None + + path = _version_path(kind) + section_name = path.section + if section_name is None: + value = payload.get(path.field) + else: + section = payload.get(section_name) + if not isinstance(section, dict): + return None + + value = section.get(path.field) + + return value if isinstance(value, str) else None + + def _upgrade_kind( kind: ObjectKind, version: str, @@ -156,10 +183,7 @@ def _upgrade_payload( when it is not a mapping, when it lacks the format's version field, or when the chain does not apply. """ - if not isinstance(payload, dict): - return None - - version = _read_version(kind, payload) + version = read_version(kind, payload) if version is None: return None @@ -180,21 +204,6 @@ def _version_path(kind: ObjectKind) -> _VersionPath: ) -def _read_version(kind: ObjectKind, data: SerializedData) -> Optional[str]: - path = _version_path(kind) - section_name = path.section - if section_name is None: - value = data.get(path.field) - else: - section = data.get(section_name) - if not isinstance(section, dict): - return None - - value = section.get(path.field) - - return value if isinstance(value, str) else None - - def _stamp( kind: ObjectKind, data: SerializedData, diff --git a/src/sampletones_core/data/metadata.py b/src/sampletones_core/data/metadata.py index aa8da0c06..9f43e9d45 100644 --- a/src/sampletones_core/data/metadata.py +++ b/src/sampletones_core/data/metadata.py @@ -61,6 +61,21 @@ def validate(self, metadata: Metadata, actual_version: str) -> None: f"Metadata application name mismatch: expected {SAMPLETONES_NAME}, got {metadata.application_name}." ) + self.validate_version(actual_version) + + def validate_version(self, actual_version: str) -> None: + """Holds the data version a file states against the one this build accepts. + + A format checks the version before it reads the rest of the file, so a file no upgrade + reaches is refused for its version. + + Args: + actual_version: The data version the file was written at. + + Raises: + IncompatibleVersionError: Of this contract's type, if the file's version departs from + the one this build accepts. + """ if compare_versions(actual_version, self.expected_version) != 0: raise self.error( f"{self.label} version mismatch: expected {self.expected_version}, got {actual_version}.", diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py index d5555e70b..80b379ba3 100644 --- a/src/sampletones_core/project/container.py +++ b/src/sampletones_core/project/container.py @@ -1,3 +1,4 @@ +import json import zipfile from pathlib import Path from typing import Dict @@ -5,8 +6,8 @@ from pydantic import ValidationError from sampletones_core.compatibility.kind import ObjectKind -from sampletones_core.compatibility.upgrade import upgrade_json -from sampletones_core.project.document import ProjectDocument +from sampletones_core.compatibility.upgrade import read_version, upgrade_json +from sampletones_core.project.document import PROJECT_DATA_CONTRACT, ProjectDocument from sampletones_core.project.project import Project from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.record import SampleRecord, VoiceRecord @@ -14,12 +15,10 @@ from sampletones_core.project.voices.voice import VoiceUnion from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures import IdentifiedCollection -from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION from sampletones_shared.constants.project import ( PROJECT_DOCUMENT_NAME, RECONSTRUCTIONS_DIRECTORY, ) -from sampletones_shared.deployment.version import compare_versions from sampletones_shared.exceptions import ( DeserializationError, IncompatibleProjectVersionError, @@ -67,13 +66,7 @@ def save(project: Project, path: Pathlike) -> None: def load(path: Pathlike) -> Project: try: with zipfile.ZipFile(path, "r") as archive: - document = ProjectDocument.model_validate_json( - upgrade_json( - ObjectKind.PROJECT, - archive.read(PROJECT_DOCUMENT_NAME), - ) - ) - ProjectContainer._validate_document(document) + document = ProjectContainer._read_document(archive.read(PROJECT_DOCUMENT_NAME)) reconstructions = ProjectContainer._read_reconstructions(archive) return ProjectContainer._build_project(document, reconstructions) except zipfile.BadZipFile as exception: @@ -86,7 +79,7 @@ def load(path: Pathlike) -> Project: raise MissingProjectDataFileError( f'The project "{Path(path)}" is incomplete: missing {exception}' ) from exception - except ValidationError as exception: + except (ValidationError, json.JSONDecodeError, UnicodeDecodeError) as exception: raise InvalidProjectDataValuesError( f'Failed to load project data from "{Path(path)}" due to validation error: {exception}', exception, @@ -101,15 +94,25 @@ def load(path: Pathlike) -> Project: ) from exception @staticmethod - def _validate_document(document: ProjectDocument) -> None: - format_version = document.format_version - if compare_versions(format_version, SAMPLETONES_PROJECT_DATA_VERSION) != 0: - raise IncompatibleProjectVersionError( - f"Project data version mismatch: expected " - f"{SAMPLETONES_PROJECT_DATA_VERSION}, got {format_version}.", - expected_version=SAMPLETONES_PROJECT_DATA_VERSION, - actual_version=format_version, - ) + def _read_document(raw: bytes) -> ProjectDocument: + """The document an archive stores, upgraded, with its version checked before its shape. + + A document no upgrade reaches keeps the shape of the build that wrote it, so the version is + checked first and the refusal names both versions. A document stating no version is read at + the version its model gives it. + + Raises: + IncompatibleProjectVersionError: If the document states a version no upgrade reaches. + json.JSONDecodeError: If the document is not JSON. + UnicodeDecodeError: If the document is not text. + ValidationError: If the document's shape departs from the one this build reads. + """ + payload = json.loads(upgrade_json(ObjectKind.PROJECT, raw)) + stated_version = read_version(ObjectKind.PROJECT, payload) + if stated_version is not None: + PROJECT_DATA_CONTRACT.validate_version(stated_version) + + return ProjectDocument.model_validate(payload) @staticmethod def _build_document(project: Project) -> ProjectDocument: diff --git a/src/sampletones_core/project/document.py b/src/sampletones_core/project/document.py index d13c00b9b..6869df409 100644 --- a/src/sampletones_core/project/document.py +++ b/src/sampletones_core/project/document.py @@ -1,15 +1,22 @@ -from typing import List +from typing import Final, List from pydantic import BaseModel, ConfigDict, Field -from sampletones_core.data import Metadata +from sampletones_core.data import Metadata, MetadataContract from sampletones_core.project.voices.record import VoiceRecord from sampletones_shared.application import SAMPLETONES_PROJECT_DATA_VERSION +from sampletones_shared.exceptions import IncompatibleProjectVersionError from .info import ProjectInfo from .settings import ProjectSettings from .song import Song +PROJECT_DATA_CONTRACT: Final[MetadataContract] = MetadataContract( + label="Project data", + expected_version=SAMPLETONES_PROJECT_DATA_VERSION, + error=IncompatibleProjectVersionError, +) + class ProjectDocument(BaseModel): """The single, validated schema for a project's ``project.json``. diff --git a/tests/integration/compatibility/test_project.py b/tests/integration/compatibility/test_project.py index 3f48a2836..da9274b03 100644 --- a/tests/integration/compatibility/test_project.py +++ b/tests/integration/compatibility/test_project.py @@ -16,7 +16,7 @@ SAMPLETONES_PROJECT_DATA_VERSION, SAMPLETONES_RECONSTRUCTION_DATA_VERSION, ) -from sampletones_shared.exceptions.project import IncompatibleProjectVersionError, InvalidProjectDataValuesError +from sampletones_shared.exceptions.project import IncompatibleProjectVersionError from tests.suite.compatibility import PROJECT_VERSION, archived, restated_document, stored_version VOICE_NAMES: Final[List[str]] = ["kick", "kick again"] @@ -62,11 +62,6 @@ def test_it_opens_all_the_same(self, loaded: Project) -> None: 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( diff --git a/tests/screens/application/old_files/test_broken_projects.py b/tests/screens/application/old_files/test_broken_projects.py index 165ff8f2b..02dd1ea38 100644 --- a/tests/screens/application/old_files/test_broken_projects.py +++ b/tests/screens/application/old_files/test_broken_projects.py @@ -128,11 +128,6 @@ def refuse_a_file_removed_after_it_was_chosen(screen: Screen) -> None: 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) diff --git a/tests/suite/compatibility.py b/tests/suite/compatibility.py index d64ae76f3..0e985459c 100644 --- a/tests/suite/compatibility.py +++ b/tests/suite/compatibility.py @@ -7,6 +7,7 @@ import msgpack from sampletones_core.compatibility.kind import ObjectKind +from sampletones_core.compatibility.upgrade import read_version from sampletones_core.data.document import decompress_document from sampletones_tools.compatibility.paths import CORPUS_DIRECTORY, archived_path @@ -48,15 +49,8 @@ def stored_document(path: Path) -> Dict[str, Any]: def stored_version(kind: ObjectKind, path: Path) -> Optional[str]: - """The data version an archived file states, read off the stored payload.""" - document = stored_document(path) - if kind is ObjectKind.PROJECT: - version = document.get(FORMAT_VERSION_FIELD) - else: - metadata = document.get(METADATA_FIELD, {}) - version = metadata.get(f"{kind.value}_data_version") - - return version if isinstance(version, str) else None + """The data version an archived file states, read off the stored payload where its format's load reads it.""" + return read_version(kind, stored_document(path)) def writing_release(path: Path) -> Optional[str]: diff --git a/tests/unit/sampletones_core/compatibility/test_upgrade.py b/tests/unit/sampletones_core/compatibility/test_upgrade.py index 93957a0ec..11b03d53c 100644 --- a/tests/unit/sampletones_core/compatibility/test_upgrade.py +++ b/tests/unit/sampletones_core/compatibility/test_upgrade.py @@ -1,12 +1,15 @@ -from typing import Final +from dataclasses import dataclass +from typing import Any, Final, Optional import pytest from sampletones_core.compatibility.kind import ObjectKind from sampletones_core.compatibility.update import VersionUpdate -from sampletones_core.compatibility.upgrade import upgrade +from sampletones_core.compatibility.upgrade import read_version, upgrade from sampletones_shared.deployment.version import Version from sampletones_shared.types.data import SerializedData +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase FIRST_MARKER: Final[str] = "first" SECOND_MARKER: Final[str] = "second" @@ -91,3 +94,68 @@ def test_duplicate_base_raises(self) -> None: with pytest.raises(ValueError): upgrade(ObjectKind.RECONSTRUCTION, "1.0", _reconstruction_data("1.0"), updates, "1.2") + + +class TestReadVersion(BaseTestSuite): + """A format's version is read where the format keeps it, and anything else there states none.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + kind: ObjectKind + payload: Any + expected: Optional[str] + + test_cases = ( + TestCase( + label="a project states it at the root", + kind=ObjectKind.PROJECT, + payload={"format_version": "1.0"}, + expected="1.0", + ), + TestCase( + label="a reconstruction states it in its metadata", + kind=ObjectKind.RECONSTRUCTION, + payload={"metadata": {"reconstruction_data_version": "2.1"}}, + expected="2.1", + ), + TestCase( + label="a library states it in its metadata", + kind=ObjectKind.LIBRARY, + payload={"metadata": {"library_data_version": "2.0"}}, + expected="2.0", + ), + TestCase( + label="a project version in metadata is another format's place", + kind=ObjectKind.PROJECT, + payload={"metadata": {"format_version": "1.0"}}, + expected=None, + ), + TestCase( + label="a reconstruction version at the root is another format's place", + kind=ObjectKind.RECONSTRUCTION, + payload={"reconstruction_data_version": "2.1"}, + expected=None, + ), + TestCase( + label="a version written as a number", + kind=ObjectKind.PROJECT, + payload={"format_version": 1.0}, + expected=None, + ), + TestCase( + label="metadata that is not a mapping", + kind=ObjectKind.RECONSTRUCTION, + payload={"metadata": ["reconstruction_data_version", "2.1"]}, + expected=None, + ), + TestCase( + label="a payload that is not a mapping", + kind=ObjectKind.PROJECT, + payload=["format_version", "1.0"], + expected=None, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_stated_version_is_read_from_its_place(self, test_case: TestCase) -> None: + assert read_version(test_case.kind, test_case.payload) == test_case.expected diff --git a/tests/unit/sampletones_core/data/test_metadata.py b/tests/unit/sampletones_core/data/test_metadata.py new file mode 100644 index 000000000..825047ac3 --- /dev/null +++ b/tests/unit/sampletones_core/data/test_metadata.py @@ -0,0 +1,57 @@ +from typing import Final + +import pytest + +from sampletones_core.data import Metadata, MetadataContract +from sampletones_shared.exceptions import IncompatibleProjectVersionError, InvalidMetadataError + +EXPECTED_VERSION: Final[str] = "1.1" +SPELLED_IN_FULL: Final[str] = "1.1.0" +OLDER_VERSION: Final[str] = "1.0" +NEWER_VERSION: Final[str] = "2.0" +FOREIGN_APPLICATION: Final[str] = "Another tracker" + + +@pytest.fixture(name="contract") +def contract_fixture() -> MetadataContract: + """A contract accepting one version and refusing any other with a project's error.""" + return MetadataContract( + label="Project data", + expected_version=EXPECTED_VERSION, + error=IncompatibleProjectVersionError, + ) + + +class TestValidateVersion: + def test_the_expected_version_passes(self, contract: MetadataContract) -> None: + contract.validate_version(EXPECTED_VERSION) + + def test_the_expected_version_spelled_in_full_passes(self, contract: MetadataContract) -> None: + contract.validate_version(SPELLED_IN_FULL) + + @pytest.mark.parametrize("actual_version", [OLDER_VERSION, NEWER_VERSION]) + def test_another_version_is_refused_naming_both(self, contract: MetadataContract, actual_version: str) -> None: + with pytest.raises(IncompatibleProjectVersionError) as refused: + contract.validate_version(actual_version) + + assert (refused.value.actual_version, refused.value.expected_version) == ( + actual_version, + EXPECTED_VERSION, + ) + + +class TestValidate: + """The full check holds the writer first and then the version, through the same version rule.""" + + def test_a_file_this_application_wrote_at_the_expected_version_passes(self, contract: MetadataContract) -> None: + contract.validate(Metadata(), EXPECTED_VERSION) + + def test_a_file_at_another_version_is_refused_for_its_version(self, contract: MetadataContract) -> None: + with pytest.raises(IncompatibleProjectVersionError) as refused: + contract.validate(Metadata(), OLDER_VERSION) + + assert refused.value.actual_version == OLDER_VERSION + + def test_a_file_another_application_wrote_is_refused_before_its_version(self, contract: MetadataContract) -> None: + with pytest.raises(InvalidMetadataError): + contract.validate(Metadata(application_name=FOREIGN_APPLICATION), OLDER_VERSION) diff --git a/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py index a94bea872..2e34f92d3 100644 --- a/tests/unit/sampletones_core/project/test_container.py +++ b/tests/unit/sampletones_core/project/test_container.py @@ -35,6 +35,8 @@ Document = Dict[str, Any] DocumentRewrite = Callable[[Document], Document] +UNREACHED_VERSION: Final[str] = "9.0" + def _rewrite_format_version(source: Path, target: Path, *, format_version: str) -> None: with zipfile.ZipFile(source, "r") as archive: @@ -61,6 +63,12 @@ def _rewrite_document(source: Path, target: Path, rewrite: DocumentRewrite) -> N archive.writestr(name, data) +def _another_builds_document(document: Document) -> Document: + """The document as a build no step reaches might write it: another version and a song it calls otherwise.""" + reshaped = {key: value for key, value in document.items() if key != "song"} + return {**reshaped, "format_version": UNREACHED_VERSION, "arrangement": document["song"]} + + def _populated_project( reconstruction_factory: ReconstructionFactory, shared: bool = False, @@ -327,6 +335,17 @@ def test_malformed_document_raises_invalid_values( with pytest.raises(InvalidProjectDataValuesError): ProjectContainer.load(path) + def test_a_document_that_is_not_text_raises_invalid_values( + self, + tmp_path: Path, + ) -> None: + path = tmp_path / "binarydoc.stp" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr(PROJECT_DOCUMENT_NAME, b"\xff\xfe\x00\xd8") + + with pytest.raises(InvalidProjectDataValuesError): + ProjectContainer.load(path) + def test_corrupt_reconstruction_raises_incorrect_data( self, tmp_path: Path, @@ -394,6 +413,61 @@ def test_incompatible_format_version_raises( assert exc_info.value.expected_version == SAMPLETONES_PROJECT_DATA_VERSION assert exc_info.value.actual_version == "9.0" + def test_a_version_no_step_reaches_is_refused_before_its_shape_is_read( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """A document at another version keeps another build's shape, so the refusal names the version.""" + original = tmp_path / "demo.stp" + ProjectContainer.save(_populated_project(reconstruction_factory), original) + reshaped = tmp_path / "reshaped.stp" + _rewrite_document(original, reshaped, _another_builds_document) + + with pytest.raises(IncompatibleProjectVersionError) as exc_info: + ProjectContainer.load(reshaped) + + assert (exc_info.value.actual_version, exc_info.value.expected_version) == ( + UNREACHED_VERSION, + SAMPLETONES_PROJECT_DATA_VERSION, + ) + + def test_the_same_shape_at_the_current_version_is_refused_for_its_shape( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + original = tmp_path / "demo.stp" + ProjectContainer.save(_populated_project(reconstruction_factory), original) + reshaped = tmp_path / "reshaped.stp" + _rewrite_document( + original, + reshaped, + lambda document: {**_another_builds_document(document), "format_version": SAMPLETONES_PROJECT_DATA_VERSION}, + ) + + with pytest.raises(InvalidProjectDataValuesError): + ProjectContainer.load(reshaped) + + def test_a_document_stating_no_version_reads_at_the_current_one( + self, + tmp_path: Path, + reconstruction_factory: ReconstructionFactory, + ) -> None: + project = _populated_project(reconstruction_factory) + original = tmp_path / "demo.stp" + ProjectContainer.save(project, original) + unstated = tmp_path / "unstated.stp" + _rewrite_document( + original, + unstated, + lambda document: {key: value for key, value in document.items() if key != "format_version"}, + ) + + loaded = ProjectContainer.load(unstated) + + assert [voice.name for voice in loaded.voices] == [voice.name for voice in project.voices] + def test_incompatible_embedded_reconstruction_version_rejected( self, tmp_path: Path, From 92113f6e5eba0961abbf12e3c21d85dce832b0c1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 18:03:22 +0200 Subject: [PATCH 04/84] Fixed: leaving where no output device is offered --- docs/development/application/playback.md | 2 + docs/development/bugs-and-todos.md | 10 +- src/sampletones_application/application.py | 74 ++++++---- .../config/managers/application.py | 10 +- .../config/managers/session.py | 10 +- .../config/session/application/audio.py | 12 +- .../view_model/shared/audio_settings.py | 18 ++- src/sampletones_core/audio/device.py | 14 ++ src/sampletones_core/audio/manager.py | 33 +++-- .../application/leaving/test_exit_shortcut.py | 5 - .../sampletones_application/test_startup.py | 132 ++++++++++++++++++ .../view_model/shared/test_audio_settings.py | 43 +++++- .../sampletones_core/audio/test_manager.py | 78 ++++++++++- 13 files changed, 371 insertions(+), 70 deletions(-) diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md index 11b4e37c0..c47ad6b56 100644 --- a/docs/development/application/playback.md +++ b/docs/development/application/playback.md @@ -110,6 +110,8 @@ Two things differ between them, and whoever asks for the audio sets each. The ** A rate is asked for once there is audio to take it, which is the first row the synthesizer renders. A device has been chosen by the time playback starts, and a format by the time a render does. A session on a machine with no output device opens on that rule, and everything that writes and does not sound works on it: editing, exporting a module and rendering to a file. +The session keeps the device the user last applied in Audio settings. A remembered device unplugged for one run is therefore looked for again in the next, the way a remembered folder is. + The song's exact length follows from the timing model before a sample is rendered. The order's length in rows gives the ticks, and the tick clock gives the samples those ticks span. That figure is what the progress bar counts against and what a finished file measures. Rendering is an exclusive operation (architecture principle 10). It occupies the application from the moment its dialog opens until that dialog closes. It joins the same busy authority as conversion and library generation, so each of the three holds the others off and every surface offering one reads a single answer. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 4d8a0dcae..0b25d95c5 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -151,10 +151,12 @@ currently out of line. An entry leaves when the code meets the contract again. ## 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/application/leaving/test_exit_shortcut.py` - reproduces it as a known failure. +* Playing on a machine that offers no output device fails without a word. The song player reads the + device's rate as playback starts, and a recording's playback reads it on its own thread, and with no + device in force both raise `ValueError: No audio device selected` where the user should read that + nothing can play. +* Apply in Audio settings on a machine that offers no output device logs a `KeyError` and leaves the + window open: the window looks the empty device choice up among the devices it lists. * 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. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 7b471834d..054c4fa19 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1,3 +1,4 @@ +from contextlib import ExitStack from pathlib import Path from typing import Any, Dict, Final, Optional, Tuple @@ -189,6 +190,7 @@ from sampletones_shared.logger import logger from sampletones_shared.paths.extensions import EXT_FILES_AUDIO from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback SEQUENCER_SAMPLE_TITLE_FORMAT: Final[str] = "{ordinal}: {name}" SEQUENCER_SAMPLE_ORDINAL_FORMAT: Final[str] = "02X" @@ -1411,18 +1413,20 @@ def _apply_audio_settings( sample_rate: SampleRate, buffer_size: BufferSize, ) -> None: - """Applies the dialog's committed device, sample rate, and buffer size. + """Applies the dialog's committed device, sample rate, and buffer size, and remembers them. Switching devices needs the output free; a source that keeps hold of it leaves the - settings as they stand and reports the failure. + settings as they stand and reports the failure. The session keeps what the user chose, + so a remembered device that is unplugged for one run is looked for again in the next. """ try: - self.audio_device_manager.configure_device(device_index, sample_rate) + current_device = self.audio_device_manager.configure_device(device_index, sample_rate) except PlaybackError as exception: self._on_playback_error(exception) return self.audio_device_manager.set_buffer_size(buffer_size) + self.session_manager.set_audio_settings(current_device, buffer_size) def _owning_project_sample(self) -> Optional[Sample]: """The project sample the open document is, found by the voice id the document remembers.""" @@ -1524,7 +1528,6 @@ def _get_active_source(self) -> Optional[AudioPlayerProtocol]: return self._shell.get_active_source() def _persist_application_state(self) -> None: - self.session_manager.set_current_audio_device(self.audio_device_manager) self._viewport_manager.save_window_state() self._save_browser_shapes() current_tab = self._shell.get_current_tab() @@ -1623,12 +1626,7 @@ def _is_project_open(self) -> bool: return self.project_controller.is_open def _exit_application(self) -> None: - self._render_coordinator.cleanup() - self._export_coordinator.cleanup() - stop_background_workers() - self._playback_router.shutdown() - self._main_tab.cleanup() - + """Stops the frames; the run lets go of what it holds once its loop has ended.""" dpg.stop_dearpygui() def _update_status(self) -> None: @@ -1671,13 +1669,17 @@ def _post_frame(self) -> None: ) CallbackQueue.process(self.layout.behavior.scheduling.queue_budget_seconds) - def _save_config(self) -> bool: + def _save_config(self) -> None: + """Writes the generation settings, ending the process with a failing status where the write fails. + + Raises: + SystemExit: If the settings could not be written. + """ try: self.config_manager.save_config() - return False except OSError as exception: logger.error_with_traceback(exception, "Failed to save configuration on exit") - return True + raise SystemExit(1) from exception def run(self) -> None: claim_render_thread() @@ -1689,17 +1691,35 @@ def run(self) -> None: except KeyboardInterrupt: return finally: - release_render_thread() - self._render_coordinator.cleanup() - self._export_coordinator.cleanup() - stop_background_workers() - self._playback_router.shutdown() - self._main_tab.cleanup() - self.library_manager.release_creator() - save_failed = self._save_config() - - self._persist_application_state() - self.audio_device_manager.terminate() - dpg.destroy_context() - if save_failed: - raise SystemExit(1) + self._teardown() + + def _teardown(self) -> None: + """Lets go of everything the run holds, once its loop has ended, whichever way it ended. + + Every step is taken whatever an earlier one raised, so a failure leaves the background work + stopped, the audio backend closed and the DearPyGui context destroyed, and it is raised once + the last step has run. + """ + with ExitStack() as steps: + for step in reversed(self._teardown_steps()): + steps.callback(step) + + def _teardown_steps(self) -> Tuple[VoidCallback, ...]: + """The steps of the teardown in the order they are taken. + + Background work stops before anything it reaches is let go of, the session is written while + the window it measures still stands, and the DearPyGui context goes last. + """ + return ( + release_render_thread, + self._render_coordinator.cleanup, + self._export_coordinator.cleanup, + stop_background_workers, + self._playback_router.shutdown, + self._main_tab.cleanup, + self.library_manager.release_creator, + self._save_config, + self._persist_application_state, + self.audio_device_manager.terminate, + dpg.destroy_context, + ) diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py index c50b0bb25..f10a1abf1 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -4,7 +4,7 @@ from sampletones_application.config.session.application.config import ApplicationConfig from sampletones_application.constants.output import OutputKind from sampletones_application.constants.playback import FollowMode -from sampletones_core.audio import AudioDeviceManager, CurrentDevice +from sampletones_core.audio import CurrentDevice from sampletones_core.constants.audio import BufferSize from sampletones_core.constants.enums import HierarchyMode from sampletones_core.data.metadata import Metadata @@ -60,8 +60,12 @@ def save(self) -> None: def toggle_favorite(self, path: Path) -> None: self.config.favorites.toggle_favorite(path) - def set_current_audio_device(self, audio_device_manager: AudioDeviceManager) -> None: - self.config.audio.set_audio_settings(audio_device_manager) + def set_audio_settings( + self, + current_device: CurrentDevice, + buffer_size: BufferSize, + ) -> None: + self.config.audio.set_audio_settings(current_device, buffer_size) @property def current_audio_device(self) -> CurrentDevice: diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index ec49b90da..f5475840e 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -9,7 +9,7 @@ from sampletones_application.config.session.state.state import ApplicationState from sampletones_application.constants.output import OutputKind from sampletones_application.constants.playback import FollowMode -from sampletones_core.audio import AudioDeviceManager, CurrentDevice +from sampletones_core.audio import CurrentDevice from sampletones_core.constants.audio import BufferSize from sampletones_core.constants.enums import HierarchyMode from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings @@ -169,11 +169,13 @@ def set_current_project(self, path: Optional[Path]) -> None: def current_project(self) -> Optional[Path]: return self._state_manager.current_project - def set_current_audio_device( + def set_audio_settings( self, - audio_device_manager: AudioDeviceManager, + current_device: CurrentDevice, + buffer_size: BufferSize, ) -> None: - self._config_manager.set_current_audio_device(audio_device_manager) + """Records the output device, its rate and the buffer size the user committed.""" + self._config_manager.set_audio_settings(current_device, buffer_size) def set_master_gain(self, value: float) -> None: self._config_manager.set_master_gain(value) diff --git a/src/sampletones_application/config/session/application/audio.py b/src/sampletones_application/config/session/application/audio.py index 60bd1cb2a..5995474d2 100644 --- a/src/sampletones_application/config/session/application/audio.py +++ b/src/sampletones_application/config/session/application/audio.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, ConfigDict, Field -from sampletones_core.audio import AudioDeviceManager, CurrentDevice +from sampletones_core.audio import CurrentDevice from sampletones_core.constants.audio import DEFAULT_BUFFER_SIZE, BufferSize from sampletones_shared.constants.audio import ( DEFAULT_MASTER_GAIN, @@ -27,6 +27,10 @@ class AudioConfig(BaseModel): description="The linear master playback gain applied to song playback.", ) - def set_audio_settings(self, audio_device_manager: AudioDeviceManager) -> None: - self.buffer_size = audio_device_manager.buffer_size - self.current_device = audio_device_manager.get_current_device() + def set_audio_settings( + self, + current_device: CurrentDevice, + buffer_size: BufferSize, + ) -> None: + self.current_device = current_device + self.buffer_size = buffer_size diff --git a/src/sampletones_application/view_model/shared/audio_settings.py b/src/sampletones_application/view_model/shared/audio_settings.py index f4303e94d..e8c69385d 100644 --- a/src/sampletones_application/view_model/shared/audio_settings.py +++ b/src/sampletones_application/view_model/shared/audio_settings.py @@ -62,9 +62,15 @@ def default_sample_rate_label(self, sample_rate_format: str) -> str: class AudioSettingsViewModel(BaseModel, frozen=True): + """The audio settings dialog's values: the devices to choose from, the one in force and its rate. + + A machine offering no output device has no device in force, so the current index and rate are + ``None`` and their labels read empty. + """ + devices: Tuple[AudioDeviceItem, ...] - current_device_index: int - current_sample_rate: SampleRate + current_device_index: Optional[int] + current_sample_rate: Optional[SampleRate] buffer_size: BufferSize master_gain: float @@ -80,8 +86,8 @@ def from_device_manager( devices=tuple( AudioDeviceItem.from_device(device) for device in audio_device_manager.list_devices().values() ), - current_device_index=current_device.device_index, - current_sample_rate=current_device.sample_rate, + current_device_index=None if current_device is None else current_device.device_index, + current_sample_rate=None if current_device is None else current_device.sample_rate, buffer_size=audio_device_manager.buffer_size, master_gain=master_gain, ) @@ -103,6 +109,10 @@ def current_device(self) -> Optional[AudioDeviceItem]: return None def current_sample_rate_label(self, sample_rate_format: str) -> str: + """The rate in force, formatted, or empty while no device is in force.""" + if self.current_sample_rate is None: + return "" + return format_sample_rate(self.current_sample_rate, sample_rate_format) @property diff --git a/src/sampletones_core/audio/device.py b/src/sampletones_core/audio/device.py index 4c8db5c82..bdab68961 100644 --- a/src/sampletones_core/audio/device.py +++ b/src/sampletones_core/audio/device.py @@ -28,6 +28,20 @@ def default(cls) -> Self: host_api=-1, ) + @classmethod + def from_device( + cls, + device: AudioDevice, + sample_rate: SampleRate, + ) -> Self: + """The configuration of ``device`` playing at ``sample_rate``.""" + return cls( + device_index=device.index, + name=device.name, + sample_rate=sample_rate, + host_api=device.host_api, + ) + class AudioDevice(BaseModel): """ diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index 9c36a6241..9b94acc5f 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -352,19 +352,25 @@ def sample_rate(self, value: SampleRate) -> None: self._sample_rate = value - def get_current_device(self) -> CurrentDevice: + def get_current_device(self) -> Optional[CurrentDevice]: """ Get a snapshot of the current device configuration. + A machine offering no output device starts with nothing selected, and a refresh can take + the selected device off the list, so both read as no current device. + Returns: - CurrentDevice object containing device index, name, sample rate, and host API. + CurrentDevice object containing device index, name, sample rate, and host API, or + ``None`` while no listed device is selected. """ - return CurrentDevice( - device_index=self.device_index, - name=self.device_name, - sample_rate=self.sample_rate, - host_api=self._devices[self.device_index].host_api, - ) + if self._device_index is None or self._sample_rate is None: + return None + + device = self._devices.get(self._device_index) + if device is None: + return None + + return CurrentDevice.from_device(device, self._sample_rate) def set_current_device(self, current_device: CurrentDevice) -> None: """ @@ -379,17 +385,18 @@ def set_current_device(self, current_device: CurrentDevice) -> None: """ device_index = self.find_device_index(current_device) if device_index != -1: - return self.configure_device( + self.configure_device( device_index=device_index, sample_rate=current_device.sample_rate, ) + return if current_device.name: logger.warning(f"Audio device '{current_device.name}' not found. " f"Falling back to default device.") else: logger.info("No device specified. Initializing the default audio device.") - return self._initialize_default_device() + self._initialize_default_device() def find_device_index( self, @@ -423,7 +430,7 @@ def configure_device( self, device_index: int, sample_rate: SampleRate, - ) -> None: + ) -> CurrentDevice: """ Configure the audio device and sample rate. @@ -438,6 +445,9 @@ def configure_device( device_index: Index of the device to configure. sample_rate: Desired sample rate in Hz. + Returns: + The device and the rate now in force. + Raises: ValueError: If the device index is not found. PlaybackError: If a handed-out stream survives its release, which leaves the device @@ -462,6 +472,7 @@ def configure_device( self.device_index = device_index self.sample_rate = sample_rate logger.info(f"Audio device configured: '{self.device_name}' (index={device_index}, sample_rate={sample_rate})") + return CurrentDevice.from_device(device, sample_rate) def set_buffer_size(self, buffer_size: BufferSize) -> None: """ diff --git a/tests/screens/application/leaving/test_exit_shortcut.py b/tests/screens/application/leaving/test_exit_shortcut.py index 7a847bd31..fa7112c6c 100644 --- a/tests/screens/application/leaving/test_exit_shortcut.py +++ b/tests/screens/application/leaving/test_exit_shortcut.py @@ -125,11 +125,6 @@ def output_device(self) -> OutputDevice: """The machine offers no output device.""" 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: """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/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 2df2f3ca7..f43085f01 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -8,6 +8,7 @@ from sampletones_application.application import Application from sampletones_application.categories.hierarchy import Tab +from sampletones_application.config.managers.application import ApplicationConfigManager from sampletones_application.config.managers.session import SessionManager from sampletones_application.config.profile import UserProfile from sampletones_application.constants.conversion import MAX_STEM_SOURCES @@ -65,8 +66,10 @@ from sampletones_application.view_model.reconstruction.envelopes import ChannelEnvelopesViewModel from sampletones_application.view_model.reconstruction.instruments import ReconstructionInstrumentsViewModel from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.audio import CurrentDevice from sampletones_core.configs import Config from sampletones_core.constants.algorithm import UNIT_DRIVE +from sampletones_core.constants.audio import BufferSize, SampleRate from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.features.envelope import Envelope from sampletones_core.project.voices.instrument import Instrument @@ -85,6 +88,21 @@ LOUD_DRIVE: Final[float] = 1.75 UNBUILT_ROW: Final[str] = "browser.row.unbuilt" EDITED_VOLUME: Final[Envelope[int]] = Envelope[int](items=(7, 7)) +REMEMBERED_DEVICE: Final[CurrentDevice] = CurrentDevice( + device_index=7, + name="Remembered headphones", + sample_rate=48000, + host_api=0, +) +SPEAKERS: Final[Dict[str, Union[int, str]]] = { + "index": 0, + "name": "Speakers", + "maxOutputChannels": 2, + "defaultSampleRate": 44100, + "hostApi": 0, +} +CHOSEN_SAMPLE_RATE: Final[SampleRate] = 48000 +CHOSEN_BUFFER_SIZE: Final[BufferSize] = 512 _DPG_DISPLAY_FUNCTIONS = [ "create_context", @@ -141,6 +159,29 @@ def _no_audio_devices() -> Generator[None, None, None]: yield +@contextmanager +def _viewport_geometry() -> Generator[None, None, None]: + """The window's place and size as the session reads them when a run leaves.""" + with ( + patch("dearpygui.dearpygui.get_viewport_pos", return_value=[0, 0]), + patch("dearpygui.dearpygui.get_viewport_width", return_value=_VIEWPORT_CLIENT_WIDTH), + patch("dearpygui.dearpygui.get_viewport_height", return_value=_VIEWPORT_CLIENT_HEIGHT), + ): + yield + + +@contextmanager +def _one_audio_device() -> Generator[None, None, None]: + """A machine offering one pair of speakers as its default output.""" + with ( + patch("pyaudio.PyAudio.get_device_count", return_value=1), + patch("pyaudio.PyAudio.get_device_info_by_index", return_value=SPEAKERS), + patch("pyaudio.PyAudio.get_default_output_device_info", return_value=SPEAKERS), + patch("pyaudio.PyAudio.is_format_supported", return_value=True), + ): + yield + + def _profile(directory: Path) -> UserProfile: """Starts the application on a profile of its own, in the state a first run finds. @@ -208,6 +249,97 @@ def test_initializes_where_nothing_can_play(self, tmp_path: Path) -> None: _application(tmp_path) +class TestLeaving: + """Leaving lets go of everything the run holds and writes the session, whatever the machine offers. + + The session keeps the output device the user last committed in Audio settings. A machine offering + no output device therefore leaves cleanly, and the device the session remembers stays remembered + while it is unplugged, as a remembered folder does. + """ + + @pytest.fixture(autouse=True) + def dpg_context(self) -> Generator[Any, Application, Any]: + dpg.create_context() + yield + stop_background_workers() + SingleThreadExecutor.reset_shutdown() + dpg.destroy_context() + + @staticmethod + def _remember_device(directory: Path) -> None: + """Writes a session whose last committed device is one this machine does not offer.""" + settings = ApplicationConfigManager(_profile(directory).config) + settings.set_audio_settings(REMEMBERED_DEVICE, CHOSEN_BUFFER_SIZE) + settings.save() + + @staticmethod + def _leave(application: Application) -> None: + """Takes the teardown a run takes once its loop has ended, leaving the context to the fixture.""" + with patch("dearpygui.dearpygui.destroy_context") as destroy_context: + application._teardown() + + destroy_context.assert_called_once_with() + + @staticmethod + def _remembered_device(directory: Path) -> CurrentDevice: + return ApplicationConfigManager(_profile(directory).config).current_audio_device + + def test_a_machine_offering_no_device_leaves_and_keeps_the_remembered_one(self, tmp_path: Path) -> None: + self._remember_device(tmp_path) + with ExitStack() as stack: + for display_patch in _display_patches(): + stack.enter_context(display_patch) + stack.enter_context(_viewport_geometry()) + stack.enter_context(_no_audio_devices()) + + application = _application(tmp_path) + assert application.audio_device_manager.get_current_device() is None + self._leave(application) + + assert self._remembered_device(tmp_path) == REMEMBERED_DEVICE + assert _profile(tmp_path).state.exists() + + def test_a_committed_device_is_what_the_session_keeps(self, tmp_path: Path) -> None: + self._remember_device(tmp_path) + with ExitStack() as stack: + for display_patch in _display_patches(): + stack.enter_context(display_patch) + stack.enter_context(_viewport_geometry()) + stack.enter_context(_one_audio_device()) + + application = _application(tmp_path) + application._apply_audio_settings( + int(SPEAKERS["index"]), + CHOSEN_SAMPLE_RATE, + CHOSEN_BUFFER_SIZE, + ) + committed = application.audio_device_manager.get_current_device() + self._leave(application) + + assert committed is not None + assert committed.sample_rate == CHOSEN_SAMPLE_RATE + assert self._remembered_device(tmp_path) == committed + + def test_a_failing_step_leaves_the_later_ones_taken(self, tmp_path: Path) -> None: + with ExitStack() as stack: + for display_patch in _display_patches(): + stack.enter_context(display_patch) + stack.enter_context(_viewport_geometry()) + stack.enter_context(_no_audio_devices()) + + application = _application(tmp_path) + with ( + patch.object(application._main_tab, "cleanup", side_effect=RuntimeError), + patch("dearpygui.dearpygui.destroy_context") as destroy_context, + pytest.raises(RuntimeError), + ): + application._teardown() + + destroy_context.assert_called_once_with() + assert application.audio_device_manager._pyaudio is None + assert _profile(tmp_path).state.exists() + + @pytest.fixture def app(tmp_path: Path) -> Generator[Any, Application, Any]: dpg.create_context() diff --git a/tests/unit/sampletones_application/view_model/shared/test_audio_settings.py b/tests/unit/sampletones_application/view_model/shared/test_audio_settings.py index f412e6b9d..6ec55fd65 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_audio_settings.py +++ b/tests/unit/sampletones_application/view_model/shared/test_audio_settings.py @@ -11,7 +11,7 @@ MasterGainReadout, ) from sampletones_core.audio import AudioDevice, CurrentDevice -from sampletones_core.constants.audio import BUFFER_SIZES, BufferSize +from sampletones_core.constants.audio import BUFFER_SIZES, BufferSize, SampleRate from sampletones_shared.constants.audio import UNITY_GAIN DEVICE_LABEL_FORMAT = "{index}: {name}" @@ -32,7 +32,7 @@ def _device(index: int, name: str) -> AudioDevice: def _view_model( devices: Dict[int, AudioDevice], - current_device: CurrentDevice, + current_device: Optional[CurrentDevice], buffer_size: BufferSize, master_gain: float = UNITY_GAIN, ) -> AudioSettingsViewModel: @@ -47,9 +47,12 @@ def _view_model( class MappingCase: label: str devices: Dict[int, AudioDevice] - current_device: CurrentDevice + current_device: Optional[CurrentDevice] buffer_size: BufferSize + current_device_index: Optional[int] + current_sample_rate: Optional[SampleRate] current_device_label: str + current_sample_rate_label: str MAPPING_CASES = [ @@ -58,21 +61,40 @@ class MappingCase: devices={0: _device(0, "Speakers")}, current_device=CurrentDevice(device_index=0, name="Speakers", sample_rate=44100, host_api=0), buffer_size=1024, + current_device_index=0, + current_sample_rate=44100, current_device_label="0: Speakers", + current_sample_rate_label="44100 Hz", ), MappingCase( label="multiple_devices", devices={0: _device(0, "Speakers"), 3: _device(3, "Headphones")}, current_device=CurrentDevice(device_index=3, name="Headphones", sample_rate=48000, host_api=0), buffer_size=256, + current_device_index=3, + current_sample_rate=48000, current_device_label="3: Headphones", + current_sample_rate_label="48000 Hz", ), MappingCase( label="no_devices", devices={}, - current_device=CurrentDevice.default(), + current_device=None, buffer_size=512, + current_device_index=None, + current_sample_rate=None, current_device_label="", + current_sample_rate_label="", + ), + MappingCase( + label="devices_with_none_in_force", + devices={0: _device(0, "Speakers")}, + current_device=None, + buffer_size=512, + current_device_index=None, + current_sample_rate=None, + current_device_label="", + current_sample_rate_label="", ), ] @@ -80,6 +102,9 @@ class MappingCase: class TestFromDeviceManager: """The projection carries display labels alongside the typed values a selection commits, so the window renders and resolves selections without formatting or parsing of its own. + + A manager with no device in force, as on a machine offering no output device, projects no + current device and no rate, and their labels read empty. """ @pytest.mark.parametrize("case", MAPPING_CASES, ids=lambda case: case.label) @@ -87,10 +112,16 @@ def test_projects_the_manager_state(self, case: MappingCase) -> None: view_model = _view_model(case.devices, case.current_device, case.buffer_size) assert view_model.devices == tuple(AudioDeviceItem.from_device(device) for device in case.devices.values()) - assert view_model.current_device_index == case.current_device.device_index - assert view_model.current_sample_rate == case.current_device.sample_rate + assert view_model.current_device_index == case.current_device_index + assert view_model.current_sample_rate == case.current_sample_rate assert view_model.buffer_size == case.buffer_size + @pytest.mark.parametrize("case", MAPPING_CASES, ids=lambda case: case.label) + def test_current_sample_rate_label_reads_the_rate_in_force(self, case: MappingCase) -> None: + view_model = _view_model(case.devices, case.current_device, case.buffer_size) + + assert view_model.current_sample_rate_label(SAMPLE_RATE_FORMAT) == case.current_sample_rate_label + def test_carries_the_master_gain(self) -> None: view_model = _view_model( {0: _device(0, "Speakers")}, diff --git a/tests/unit/sampletones_core/audio/test_manager.py b/tests/unit/sampletones_core/audio/test_manager.py index 0417e6cb5..bf5c21166 100644 --- a/tests/unit/sampletones_core/audio/test_manager.py +++ b/tests/unit/sampletones_core/audio/test_manager.py @@ -1,13 +1,14 @@ import threading from dataclasses import dataclass -from typing import Callable, Final, List +from typing import Callable, Dict, Final, Iterator, List, Union from unittest.mock import MagicMock, patch import numpy as np import pytest +from sampletones_core.audio.device import CurrentDevice from sampletones_core.audio.manager import AudioDeviceManager -from sampletones_core.constants.audio import START_OF_AUDIO +from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE, START_OF_AUDIO, SampleRate from sampletones_shared.exceptions import PlaybackError from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -15,6 +16,18 @@ _LOW = 0 _HIGH = 1 _RELEASE_TIMEOUT: Final[float] = 5.0 +_BACKEND: Final[str] = "sampletones_core.audio.manager.pyaudio.PyAudio" +_SPEAKERS_INDEX: Final[int] = 0 +_SPEAKERS_NAME: Final[str] = "Speakers" +_SPEAKERS_HOST_API: Final[int] = 2 +_CHOSEN_RATE: Final[SampleRate] = 48000 +_SPEAKERS: Final[Dict[str, Union[int, str]]] = { + "index": _SPEAKERS_INDEX, + "name": _SPEAKERS_NAME, + "maxOutputChannels": 2, + "defaultSampleRate": DEFAULT_SAMPLE_RATE, + "hostApi": _SPEAKERS_HOST_API, +} def _manager() -> AudioDeviceManager: @@ -349,3 +362,64 @@ def test_a_release_may_hand_its_stream_back_from_the_writing_thread(self) -> Non assert owner.handed_back.is_set() assert manager._pyaudio is None + + +class TestCurrentDevice: + """The device in force reads as ``None`` wherever nothing is selected. + + A machine offering no output device starts with nothing selected, and a refresh that takes the + selected device off the list leaves nothing selected too. Each answers ``None``, where a machine + with a device answers the device and the rate it plays at. + """ + + @pytest.fixture(name="backend") + def backend_fixture(self) -> Iterator[MagicMock]: + """The audio backend of a machine offering one pair of speakers as its default output.""" + with patch(_BACKEND) as backend_class: + backend = backend_class.return_value + backend.get_device_count.return_value = 1 + backend.get_device_info_by_index.return_value = _SPEAKERS + backend.get_default_output_device_info.return_value = _SPEAKERS + yield backend + + @pytest.fixture(name="silent_backend") + def silent_backend_fixture(self) -> Iterator[MagicMock]: + """The audio backend of a machine offering no output device at all.""" + with patch(_BACKEND) as backend_class: + backend = backend_class.return_value + backend.get_device_count.return_value = 0 + backend.get_default_output_device_info.side_effect = OSError + yield backend + + def test_the_default_device_is_in_force(self, backend: MagicMock) -> None: + manager = AudioDeviceManager() + + assert manager.get_current_device() == CurrentDevice( + device_index=_SPEAKERS_INDEX, + name=_SPEAKERS_NAME, + sample_rate=DEFAULT_SAMPLE_RATE, + host_api=_SPEAKERS_HOST_API, + ) + + def test_a_machine_offering_no_device_has_none_in_force(self, silent_backend: MagicMock) -> None: + manager = AudioDeviceManager() + + assert manager.list_devices() == {} + assert manager.get_current_device() is None + + def test_a_device_taken_off_the_list_leaves_none_in_force(self, backend: MagicMock) -> None: + manager = AudioDeviceManager() + assert manager.get_current_device() is not None + + backend.get_device_count.return_value = 0 + manager.refresh_devices() + + assert manager.get_current_device() is None + + def test_configuring_answers_the_device_now_in_force(self, backend: MagicMock) -> None: + manager = AudioDeviceManager() + + configured = manager.configure_device(_SPEAKERS_INDEX, _CHOSEN_RATE) + + assert configured.sample_rate == _CHOSEN_RATE + assert manager.get_current_device() == configured From 053b1ae710aa2eec0b8e2be33f71fddc1c6fd693 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 18:05:40 +0200 Subject: [PATCH 05/84] Fixed: envelope bars and stepper buttons answering a press that began elsewhere --- docs/development/bugs-and-todos.md | 4 - .../ui/elements/graphs/bar.py | 33 +++- .../ui/elements/plus_minus_buttons.py | 129 ++++++++++++---- .../prompts/open_voice/test_open_voice.py | 5 - .../ui/elements/graphs/test_bar.py | 94 ++++++++++-- .../ui/elements/test_plus_minus_buttons.py | 141 ++++++++++++++---- 6 files changed, 317 insertions(+), 89 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index aa90ad860..672fda9e2 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -180,10 +180,6 @@ currently out of line. An entry leaves when the code meets the contract again. * 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/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/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/modals/test_run_ending_behind_a_dialog.py` reproduces it as a known failure. diff --git a/src/sampletones_application/ui/elements/graphs/bar.py b/src/sampletones_application/ui/elements/graphs/bar.py index 26b716d23..f0f93d0a2 100644 --- a/src/sampletones_application/ui/elements/graphs/bar.py +++ b/src/sampletones_application/ui/elements/graphs/bar.py @@ -43,6 +43,14 @@ class DrawStroke: class GUIBarGraph(GUIGraph[BarLayer]): + """A plot of bars the reader draws by pressing and dragging over them. + + A press belongs to the plot it went down on: the plot's own click starts it and the release + ends it, and bars are drawn only in between. A button already held as the plot comes under + the pointer, such as the second press of a double-click that brought the plot forward, draws + nothing. + """ + tag: str parent: str width: int @@ -86,6 +94,7 @@ def __init__( self.on_bar_point_hovered: Optional[OnBarPointHoveredCallback] = None self._draw_stroke: Optional[DrawStroke] = None + self._pressed = False _min_x = layout.graph.min_x _max_x = layout.graph.max_x @@ -139,9 +148,27 @@ def _create_content(self) -> None: def _setup_handlers(self) -> None: super()._setup_handlers() + dpg.add_item_clicked_handler( + button=dpg.mvMouseButton_Left, + callback=self._on_press, + parent=self.event_handler_tag, + ) with dpg.handler_registry(tag=self.mouse_handler_tag): dpg.add_mouse_move_handler(callback=self._on_mouse_action) - dpg.add_mouse_click_handler(callback=self._on_mouse_action) + dpg.add_mouse_release_handler( + button=dpg.mvMouseButton_Left, + callback=self._on_release, + ) + + def _on_press(self, sender: Sender) -> None: + """Starts a press on the plot, which draws the bar it lands on.""" + self._pressed = True + self._on_mouse_action(sender) + + def _on_release(self, _sender: Sender) -> None: + """Ends the press, so the next stroke starts afresh.""" + self._pressed = False + self._draw_stroke = None def _on_hover(self, sender: Sender, app_data: int, user_data: Any) -> None: super()._on_hover(sender, app_data, user_data) @@ -358,9 +385,7 @@ def _on_mouse_action(self, _sender: Sender) -> None: self._set_hover_bar_position(bar_index, clamped_y) self.call(self.on_bar_point_hovered, name, bar_index) - if self._presses_a_bar(mouse_y) and ( - dpg.is_mouse_button_down(dpg.mvMouseButton_Left) or dpg.is_mouse_button_clicked(dpg.mvMouseButton_Left) - ): + if self._pressed and self._presses_a_bar(mouse_y): self._draw_bar(layer, bar_index, clamped_y, previous_stroke) def _presses_a_bar(self, mouse_y: float) -> bool: diff --git a/src/sampletones_application/ui/elements/plus_minus_buttons.py b/src/sampletones_application/ui/elements/plus_minus_buttons.py index 0f2dbc79c..edf5c2758 100644 --- a/src/sampletones_application/ui/elements/plus_minus_buttons.py +++ b/src/sampletones_application/ui/elements/plus_minus_buttons.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from enum import Enum, auto from typing import Any, Optional @@ -33,6 +34,14 @@ class PlusMinusOrder(Enum): PLUS_FIRST = auto() +@dataclass +class HeldPress: + """A press that went down on one of the pair's buttons, and the time left before it repeats.""" + + direction: int + remaining: float + + class GUIPlusMinusButtons(CallbackMixin): """A styled pair of ``[-]``/``[+]`` buttons laid out in two table columns. @@ -40,8 +49,10 @@ class GUIPlusMinusButtons(CallbackMixin): decrements and ``[+]`` always increments, while ``order`` chooses which column each sign occupies. With ``hold_repeat`` a held button repeats its press after an initial delay, matching the stepping feel of a numeric field; otherwise each button fires once - per click. Either button can be enabled or disabled independently, so a control can - gray out a step that would have no effect. + per click. A hold belongs to the button the press went down on and repeats while the + pointer stays on it, so a press carried in from elsewhere steps nothing. Either button + can be enabled or disabled independently, so a control can gray out a step that would + have no effect. """ def __init__( @@ -66,13 +77,14 @@ def __init__( self._hold_repeat = hold_repeat self._font = font - self._hold_direction: Optional[int] = None - self._hold_timer: Optional[float] = None + self._held: Optional[HeldPress] = None self._table_tag = compose_tag(tag, SUF_TABLE) self._decrement_button_tag = compose_tag(tag, SUF_BUTTON_DECREMENT) self._increment_button_tag = compose_tag(tag, SUF_BUTTON_INCREMENT) self._mouse_handler_tag = compose_tag(tag, SUF_HANDLER_REGISTRY) + self._decrement_handler_tag = compose_tag(self._decrement_button_tag, SUF_HANDLER_REGISTRY) + self._increment_handler_tag = compose_tag(self._increment_button_tag, SUF_HANDLER_REGISTRY) self._decrement_button: Optional[GUIButton] = None self._increment_button: Optional[GUIButton] = None @@ -163,13 +175,31 @@ def _add_button( def _clear_existing_items(self) -> None: """Removes the widget's own items from a prior build so it rebuilds cleanly under the same - tags. Deleting the table removes its buttons; the mouse handler registry lives outside the - table, so it is removed on its own.""" - for tag in (self._mouse_handler_tag, self._table_tag): + tags. Deleting the table removes its buttons; the handler registries live outside the + table, so each is removed on its own.""" + for tag in ( + self._mouse_handler_tag, + self._decrement_handler_tag, + self._increment_handler_tag, + self._table_tag, + ): if dpg.does_item_exist(tag): dpg_delete_item(tag) def _setup_button_hold_handlers(self) -> None: + if self._decrement_button is not None: + self._bind_press_handler( + self._decrement_button.button_tag, + self._decrement_handler_tag, + self._on_decrement_pressed, + ) + if self._increment_button is not None: + self._bind_press_handler( + self._increment_button.button_tag, + self._increment_handler_tag, + self._on_increment_pressed, + ) + with dpg.handler_registry(tag=self._mouse_handler_tag): dpg.add_mouse_down_handler( button=dpg.mvMouseButton_Left, @@ -180,6 +210,24 @@ def _setup_button_hold_handlers(self) -> None: callback=self._on_mouse_release, ) + @staticmethod + def _bind_press_handler( + button_tag: str, + handler_tag: str, + on_pressed: VoidCallback, + ) -> None: + """Hears a press going down on one button, which is where a hold begins. + + The handler goes on the button itself, since the group wrapping it reports no clicks. + """ + with dpg.item_handler_registry(tag=handler_tag): + dpg.add_item_clicked_handler( + button=dpg.mvMouseButton_Left, + callback=on_pressed, + ) + + dpg.bind_item_handler_registry(button_tag, handler_tag) + def _step(self, direction: int) -> None: if direction > 0: self.call(self.on_increment) @@ -192,6 +240,19 @@ def _on_increment(self, *_arguments: Any) -> None: def _on_decrement(self, *_arguments: Any) -> None: self._step(-1) + def _on_decrement_pressed(self) -> None: + self._arm_hold(-1) + + def _on_increment_pressed(self) -> None: + self._arm_hold(1) + + def _arm_hold(self, direction: int) -> None: + """Starts a hold on the button a press went down on, with a longer delay before the first repeat.""" + self._held = HeldPress( + direction=direction, + remaining=HOLD_INITIAL_DELAY_FACTOR * self._layout.hold_delay, + ) + def _on_mouse_down( self, sender: Sender, @@ -202,47 +263,49 @@ def _on_mouse_down( dpg_delete_item(sender) return - is_decrement = self._decrement_button is not None and bool(self._decrement_button.is_item_hovered()) - is_increment = self._increment_button is not None and bool(self._increment_button.is_item_hovered()) - direction = self._update_hold_timer( - is_decrement, - is_increment, - dpg.get_delta_time(), - ) + direction = self._advance_hold(self._held_button_hovered(), dpg.get_delta_time()) if direction is not None: self._step(direction) + def _held_button_hovered(self) -> bool: + """Whether the pointer stands on the button the hold began on.""" + if self._held is None: + return False + + button = self._increment_button if self._held.direction > 0 else self._decrement_button + return button is not None and bool(button.is_item_hovered()) + def _on_mouse_release( self, _sender: Sender, _app_data: Any, _user_data: Any, ) -> None: - self._hold_timer = None - self._hold_direction = None + self._held = None - def _update_hold_timer( + def _advance_hold( self, - is_decrement: bool, - is_increment: bool, + hovered: bool, delta_time: float, ) -> Optional[int]: - """Drives click-and-hold repetition: the first frame of a press arms the timer with a longer - initial delay, and each later frame repeats once the delay elapses. Returns the step direction on - the frames that should advance the value, otherwise None. - """ - if not is_decrement and not is_increment: - return None + """Counts a hold down by one frame, answering its direction on each frame that repeats it. - direction = -1 if is_decrement else 1 - if self._hold_direction != direction or self._hold_timer is None: - self._hold_direction = direction - self._hold_timer = HOLD_INITIAL_DELAY_FACTOR * self._layout.hold_delay + The hold counts while the pointer stays on the button the press went down on, and each + repeat starts the shorter delay to the next one. + + Args: + hovered: Whether the pointer stands on the button the hold began on. + delta_time: The seconds the frame took. + + Returns: + Optional[int]: The step direction on a frame that repeats the press, otherwise ``None``. + """ + if self._held is None or not hovered: return None - self._hold_timer -= delta_time - if self._hold_timer > 0: + self._held.remaining -= delta_time + if self._held.remaining > 0: return None - self._hold_timer = self._layout.hold_delay - return direction + self._held.remaining = self._layout.hold_delay + return self._held.direction diff --git a/tests/screens/prompts/open_voice/test_open_voice.py b/tests/screens/prompts/open_voice/test_open_voice.py index 80e3ec6cc..d268e084f 100644 --- a/tests/screens/prompts/open_voice/test_open_voice.py +++ b/tests/screens/prompts/open_voice/test_open_voice.py @@ -248,11 +248,6 @@ def startup(self) -> Startup: """Starts with the song open and no reconstruction.""" 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: """The volume drawn after a held double-click equals the volume drawn after a plain open. diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py b/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py index 0c4dbde3d..4c1ccc2ef 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py @@ -42,6 +42,7 @@ def _graph() -> GUIBarGraph: ) } graph._draw_stroke = None + graph._pressed = False graph.on_bar_point_clicked = None graph.on_bar_point_hovered = None return graph @@ -56,26 +57,49 @@ def _reserve_band(graph: GUIBarGraph, share: float) -> Tuple[float, float]: return graph.reserve_band(share) -def _press( - graph: GUIBarGraph, +def _point_at( monkeypatch: pytest.MonkeyPatch, position: Tuple[float, float], ) -> None: - """One frame of the left button held with the pointer standing at ``position`` in the plot. + """Stands the pointer at ``position`` in the plot. The plot's own items are DearPyGui's, so the reading answers that none of them is built and - the case reads the values the press leaves behind. + the case reads the values a gesture leaves behind. """ monkeypatch.setattr(bar_module.dpg, "does_item_exist", lambda tag: False) monkeypatch.setattr(bar_module, "dpg_is_item_hovered", lambda tag: True) monkeypatch.setattr(bar_module, "dpg_configure_item", lambda tag, **kwargs: None) monkeypatch.setattr(bar_module.dpg, "is_key_down", lambda key: False) monkeypatch.setattr(bar_module.dpg, "get_plot_mouse_pos", lambda: position) - monkeypatch.setattr(bar_module.dpg, "is_mouse_button_down", lambda button: True) - monkeypatch.setattr(bar_module.dpg, "is_mouse_button_clicked", lambda button: False) + + +def _press( + graph: GUIBarGraph, + monkeypatch: pytest.MonkeyPatch, + position: Tuple[float, float], +) -> None: + """A left press going down on the plot with the pointer standing at ``position``.""" + _point_at(monkeypatch, position) + graph._on_press(PLOT_TAG) + + +def _hover( + graph: GUIBarGraph, + monkeypatch: pytest.MonkeyPatch, + position: Tuple[float, float], +) -> None: + """One frame of the pointer standing at ``position``, as the hover and the move report it.""" + _point_at(monkeypatch, position) graph._on_mouse_action(PLOT_TAG) +def _with_bar(index: int, value: float) -> List[int]: + """The plotted values with one bar written.""" + expected = list(BAR_VALUES) + expected[index] = int(value) + return expected + + class TestPressingTheBars: """A press on the grid the bars stand on writes the value it lands at.""" @@ -84,8 +108,58 @@ def test_a_press_writes_the_value_it_lands_at(self, monkeypatch: pytest.MonkeyPa _press(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) - expected = list(BAR_VALUES) - expected[PRESSED_BAR] = int(PRESSED_VALUE) + assert list(graph.layers[LAYER_NAME].y_data) == _with_bar(PRESSED_BAR, PRESSED_VALUE) + + def test_a_drag_from_the_press_writes_every_bar_it_crosses(self, monkeypatch: pytest.MonkeyPatch) -> None: + graph = _graph() + last_bar = len(BAR_VALUES) - 1 + + _press(graph, monkeypatch, (0.5, PRESSED_VALUE)) + _hover(graph, monkeypatch, (last_bar + 0.5, PRESSED_VALUE)) + + assert list(graph.layers[LAYER_NAME].y_data) == [int(PRESSED_VALUE)] * len(BAR_VALUES) + + +class TestAPressBelongsToThePlotItWentDownOn: + """A button held before the plot came under the pointer draws nothing, and a release ends a press. + + A double-click on a voice brings the plot forward while its second press is still down. + """ + + def test_a_button_held_as_the_plot_comes_under_the_pointer_draws_nothing( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph = _graph() + + _hover(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + + assert list(graph.layers[LAYER_NAME].y_data) == list(BAR_VALUES) + _press(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + assert list(graph.layers[LAYER_NAME].y_data) == _with_bar(PRESSED_BAR, PRESSED_VALUE) + + def test_a_button_held_carries_on_hovering_without_drawing(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The pointer still marks the bar it stands over, so a reader sees where a press would land.""" + graph = _graph() + hovered: List[Tuple[Optional[str], Optional[int]]] = [] + graph.on_bar_point_hovered = lambda name, index: hovered.append((name, index)) + + _hover(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + + assert hovered == [(LAYER_NAME, PRESSED_BAR)] + + def test_a_release_ends_the_press(self, monkeypatch: pytest.MonkeyPatch) -> None: + graph = _graph() + last_bar = len(BAR_VALUES) - 1 + _press(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + + graph._on_release(PLOT_TAG) + _hover(graph, monkeypatch, (last_bar + 0.5, PRESSED_VALUE)) + + assert list(graph.layers[LAYER_NAME].y_data) == _with_bar(PRESSED_BAR, PRESSED_VALUE) + _press(graph, monkeypatch, (last_bar + 0.5, PRESSED_VALUE)) + expected = _with_bar(PRESSED_BAR, PRESSED_VALUE) + expected[last_bar] = int(PRESSED_VALUE) assert list(graph.layers[LAYER_NAME].y_data) == expected @@ -127,6 +201,4 @@ def test_a_press_above_the_band_writes_as_it_did_before_the_band( _press(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) - expected = list(BAR_VALUES) - expected[PRESSED_BAR] = int(PRESSED_VALUE) - assert list(graph.layers[LAYER_NAME].y_data) == expected + assert list(graph.layers[LAYER_NAME].y_data) == _with_bar(PRESSED_BAR, PRESSED_VALUE) diff --git a/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py b/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py index 4eb1a879e..a7376ed64 100644 --- a/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py +++ b/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py @@ -1,9 +1,17 @@ -from typing import List +from dataclasses import dataclass +from typing import Callable, List, Optional +from unittest.mock import MagicMock + +import pytest from sampletones_application.layout.general.plus_minus_buttons import ( PlusMinusButtonsLayout, ) -from sampletones_application.ui.elements.plus_minus_buttons import GUIPlusMinusButtons +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.plus_minus_buttons import ( + HOLD_INITIAL_DELAY_FACTOR, + GUIPlusMinusButtons, +) LAYOUT = PlusMinusButtonsLayout( button_width=30, @@ -11,30 +19,57 @@ hold_delay=0.075, ) +FIRST_REPEAT = HOLD_INITIAL_DELAY_FACTOR * LAYOUT.hold_delay +FRAME = LAYOUT.hold_delay / 10 + + +@dataclass +class Pointer: + """Which of the pair's buttons the pointer stands on, as their hover reads it.""" + + on_increment: bool = False + on_decrement: bool = False + + +def _button(hovered: Callable[[], bool]) -> GUIButton: + button = MagicMock(spec=GUIButton) + button.is_item_hovered.side_effect = hovered + return button + + +@pytest.fixture +def pointer() -> Pointer: + return Pointer() + -def _buttons() -> GUIPlusMinusButtons: +@pytest.fixture +def buttons(pointer: Pointer) -> GUIPlusMinusButtons: """A pair carrying only the state the tested methods touch, bypassing the DearPyGui-dependent constructor.""" - buttons = GUIPlusMinusButtons.__new__(GUIPlusMinusButtons) - buttons.on_increment = None - buttons.on_decrement = None - buttons._layout = LAYOUT - buttons._hold_direction = None - buttons._hold_timer = None - return buttons + pair = GUIPlusMinusButtons.__new__(GUIPlusMinusButtons) + pair.on_increment = None + pair.on_decrement = None + pair._layout = LAYOUT + pair._held = None + pair._increment_button = _button(lambda: pointer.on_increment) + pair._decrement_button = _button(lambda: pointer.on_decrement) + return pair + + +def _frame(buttons: GUIPlusMinusButtons, seconds: float) -> Optional[int]: + """One frame of the left button down, answering the direction it repeats in, if any.""" + return buttons._advance_hold(buttons._held_button_hovered(), seconds) class TestStep: - def test_step_up_calls_increment(self) -> None: - buttons = _buttons() + def test_step_up_calls_increment(self, buttons: GUIPlusMinusButtons) -> None: calls: List[str] = [] buttons.on_increment = lambda: calls.append("increment") buttons.on_decrement = lambda: calls.append("decrement") buttons._step(1) assert calls == ["increment"] - def test_step_down_calls_decrement(self) -> None: - buttons = _buttons() + def test_step_down_calls_decrement(self, buttons: GUIPlusMinusButtons) -> None: calls: List[str] = [] buttons.on_increment = lambda: calls.append("increment") buttons.on_decrement = lambda: calls.append("decrement") @@ -42,25 +77,67 @@ def test_step_down_calls_decrement(self) -> None: assert calls == ["decrement"] -class TestHoldTimer: - def test_first_press_arms_timer_without_stepping(self) -> None: - buttons = _buttons() - assert buttons._update_hold_timer(False, True, 0.0) is None - assert buttons._hold_timer is not None +class TestAHoldBelongsToTheButtonItWentDownOn: + """A press on a button repeats while held over it, and a press carried in from elsewhere steps nothing.""" + + def test_a_press_waits_the_longer_delay_before_its_first_repeat( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + pointer.on_increment = True + buttons._on_increment_pressed() + + assert _frame(buttons, FIRST_REPEAT - FRAME) is None + assert _frame(buttons, 2 * FRAME) == 1 + + def test_each_later_repeat_follows_the_shorter_delay( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + pointer.on_decrement = True + buttons._on_decrement_pressed() + _frame(buttons, FIRST_REPEAT) - def test_repeats_once_the_delay_elapses(self) -> None: - buttons = _buttons() - buttons._update_hold_timer(False, True, 0.0) - buttons._hold_timer = 0.0 - assert buttons._update_hold_timer(False, True, 0.01) == 1 + assert _frame(buttons, LAYOUT.hold_delay - FRAME) is None + assert _frame(buttons, 2 * FRAME) == -1 - def test_no_button_pressed_returns_none(self) -> None: - buttons = _buttons() - assert buttons._update_hold_timer(False, False, 0.05) is None + def test_a_press_carried_onto_a_button_steps_nothing( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + pointer.on_increment = True + + assert [_frame(buttons, FIRST_REPEAT) for _ in range(3)] == [None, None, None] + buttons._on_increment_pressed() + assert _frame(buttons, FIRST_REPEAT) == 1 + + def test_a_press_slid_onto_the_other_button_steps_nothing( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + pointer.on_increment = True + buttons._on_increment_pressed() + pointer.on_increment = False + pointer.on_decrement = True + + assert _frame(buttons, FIRST_REPEAT) is None + pointer.on_decrement = False + pointer.on_increment = True + assert _frame(buttons, FIRST_REPEAT) == 1 + + def test_a_release_ends_the_hold( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + pointer.on_increment = True + buttons._on_increment_pressed() - def test_release_clears_hold_state(self) -> None: - buttons = _buttons() - buttons._update_hold_timer(True, False, 0.0) buttons._on_mouse_release(0, None, None) - assert buttons._hold_timer is None - assert buttons._hold_direction is None + + assert buttons._held is None + assert _frame(buttons, FIRST_REPEAT) is None From 45935c276304c1b36da8844065d9ecfb42c2b5f2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 18:10:22 +0200 Subject: [PATCH 06/84] Fixed: saving a project opened at start to its own file --- docs/development/architecture.md | 2 +- docs/development/bugs-and-todos.md | 3 - .../coordinators/project.py | 12 +- .../logic/project/manager.py | 35 ++++- .../song/test_retuning_and_saving.py | 7 - .../coordinators/test_project.py | 98 ++++++++++++-- .../logic/project/test_manager.py | 123 ++++++++++++++++++ 7 files changed, 243 insertions(+), 37 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 2bebb5883..9644c0714 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -186,7 +186,7 @@ A view model is the UI's contract with the logic layer: it states exactly what d Owns domain state and implements the state-machine transitions that govern it, knowing nothing of the UI framework. -*Managers* own a domain object's lifecycle (load, save, close). They hold the current object and a `Session` that tracks dirty state, and they fire `CallbackMixin` callbacks when the state changes. +*Managers* own a domain object's lifecycle (load, save, close). They hold the current object, the file it was last loaded from or saved to, and a `Session` that tracks dirty state, and they fire `CallbackMixin` callbacks when the state changes. Whoever saves or remembers a document reads its file from the manager, so a document opened by any route is saved where it came from. *Controllers* are thin mutation façades over a manager. `ProjectController` exposes named, typed mutation methods and emits a finer-grained callback per mutation kind, so the UI answers exactly what changed. Its `batch()` widens that grain to a whole gesture. Each mutation still applies the moment it is made, while the callbacks wait for the scope to close and then arrive once each. A gesture that writes hundreds of rows therefore rebuilds its subscribers once. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 0b25d95c5..2239400fe 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -209,9 +209,6 @@ currently out of line. An entry leaves when the code meets the contract again. * 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/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/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 diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index c255b2576..5d65f0dde 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -92,6 +92,7 @@ def __init__( self._truncation_messages = TruncationMessages.for_project(language_manager) self._on_tab_switch = on_tab_switch self._project_manager.session.on_state_changed = on_session_state_changed + self._project_manager.on_path_changed = session_manager.set_current_project export_service.subscribe(self._on_export_result) @@ -127,12 +128,13 @@ def open_project() -> None: ) def load_project_safely(self, path: Path) -> None: - """Loads the persisted project when the application starts. + """Loads the project a run starts on, the one the session remembers or the one it was given. Startup restore happens automatically, so a failed load is recovered silently: the stale session pointer is cleared so a missing, moved, or corrupt file leaves the next launch starting from a clean slate. Only known domain and I/O failures - are absorbed; unexpected errors propagate. + are absorbed; unexpected errors propagate. A project that loads stands for its file, + so a later save writes there. """ try: self._project_controller.load(path) @@ -193,7 +195,7 @@ def _write_project(self) -> SaveOutcome: the reader closes the file dialog. The reader asked to go on, so the save goes on without a word of its own, and whatever the prompt guards opens alone. """ - filepath = self._session_manager.current_project + filepath = self._project_manager.path if filepath is None: return self._write_to_chosen_file() @@ -300,12 +302,10 @@ def _handle_export_project(self, filepath: Path, export_format: ExportFormat) -> def _new(self) -> None: self._project_controller.new() - self._session_manager.set_current_project(None) self._on_tab_switch(Tab.SEQUENCER) def _close(self) -> None: self._project_controller.close() - self._session_manager.set_current_project(None) def _load(self, filepath: Path) -> None: try: @@ -318,7 +318,6 @@ def _load(self, filepath: Path) -> None: self._dialogs.show_error(exception) return - self._session_manager.set_current_project(filepath) self._on_tab_switch(Tab.SEQUENCER) def _write(self, filepath: Path) -> SaveOutcome: @@ -335,7 +334,6 @@ def _write(self, filepath: Path) -> SaveOutcome: ) return SaveOutcome.FAILED - self._session_manager.set_current_project(filepath) return SaveOutcome.WRITTEN def _on_export_result(self, result: ExportResult) -> None: diff --git a/src/sampletones_application/logic/project/manager.py b/src/sampletones_application/logic/project/manager.py index 69a40d2e5..6cd86ced4 100644 --- a/src/sampletones_application/logic/project/manager.py +++ b/src/sampletones_application/logic/project/manager.py @@ -1,28 +1,42 @@ from pathlib import Path +from typing import Callable, Optional from sampletones_core.project import Project, ProjectContainer from sampletones_shared.logger import logger +from sampletones_shared.utils.callbacks import CallbackMixin from .session import ProjectSession +OnPathChangedCallback = Callable[[Optional[Path]], None] -class ProjectManager: + +class ProjectManager(CallbackMixin): """ - The single authority on which project is currently open and whether it is clean. + The single authority on which project is currently open, which file it stands for, and whether it is clean. - - It is a passive data holder; lifecycle events are emitted by its ``session``. - - Callers that need to react to lifecycle transitions should subscribe to - ``session.on_state_changed``. + - Lifecycle events are emitted by its ``session``; callers that need to react to lifecycle + transitions subscribe to ``session.on_state_changed``. + - The project stands for the file it was last loaded from or saved to, its :attr:`path`. A + project created here and never saved stands for none. ``on_path_changed`` reports each new + path, so the session remembers the project the next run reopens. """ def __init__(self) -> None: self._session: ProjectSession = ProjectSession() self._current: Project = Project.create() + self._path: Optional[Path] = None + + self.on_path_changed: Optional[OnPathChangedCallback] = None @property def current(self) -> Project: return self._current + @property + def path(self) -> Optional[Path]: + """The file the open project was last loaded from or saved to, ``None`` for a project never written.""" + return self._path + @property def session(self) -> ProjectSession: return self._session @@ -41,22 +55,33 @@ def is_open(self) -> bool: def new(self) -> None: self._current = Project.create() + self._set_path(None) self._session.mark_loaded("") def close(self) -> None: self._current = Project.create() + self._set_path(None) self._session.mark_closed() def load(self, path: Path) -> None: logger.info(f"Loading project: {logger.format_path(path)}") self._current = ProjectContainer.load(path) + self._set_path(path) self._session.mark_loaded(path.stem) logger.info(f"Project {logger.format_path(path)} loaded successfully") def save(self, path: Path) -> None: ProjectContainer.save(self._current, path) + self._set_path(path) self._session.mark_saved(path.stem) + def _set_path(self, path: Optional[Path]) -> None: + if path == self._path: + return + + self._path = path + self.call(self.on_path_changed, path) + def mark_updated(self) -> None: self._session.mark_updated() diff --git a/tests/screens/sequencer/song/test_retuning_and_saving.py b/tests/screens/sequencer/song/test_retuning_and_saving.py index d0597f037..0f587e06f 100644 --- a/tests/screens/sequencer/song/test_retuning_and_saving.py +++ b/tests/screens/sequencer/song/test_retuning_and_saving.py @@ -1,8 +1,6 @@ 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 @@ -86,11 +84,6 @@ class TestSavingAProjectOpenedAtStart: 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) diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index 1719a2e13..a37df514e 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, Optional +from typing import Callable, Final, Optional from unittest.mock import MagicMock import pytest @@ -9,6 +9,8 @@ from sampletones_application.categories.skipped import MAX_REPORTED_ROWS from sampletones_application.coordinators import project as project_module from sampletones_application.coordinators.project import ProjectCoordinator +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.paths import LANG_EN from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess @@ -16,6 +18,7 @@ from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS, SkippedRow, SkipReason from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.format import ExportFormat +from sampletones_core.project import ProjectContainer from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_shared.exceptions import ( @@ -31,6 +34,9 @@ from tests.suite.case import BaseRegularTestCase from tests.suite.silent_rows import MISSING_VOICE_ID, SILENT_CHANNEL +ORIGINAL_TITLE: Final[str] = "As saved" +EDITED_TITLE: Final[str] = "As edited" + @pytest.fixture def project_coordinator() -> ProjectCoordinator: @@ -48,17 +54,81 @@ def project_coordinator() -> ProjectCoordinator: ) -class TestProjectRestoreSuccess: - def test_loads_and_keeps_session_pointer( +class TestAProjectOpenedAtStart: + """A project a run starts on stands for its file, as one opened by hand does. + + The session remembers the file, and Save writes there at once. A project the run never opened + asks where to save it. + """ + + @pytest.fixture(name="save_dialog") + def save_dialog_fixture(self, monkeypatch: pytest.MonkeyPatch) -> MagicMock: + dialog = MagicMock(return_value=None) + monkeypatch.setattr(project_module, "save_file_dialog", dialog) + return dialog + + @pytest.fixture(name="saved_project") + def saved_project_fixture(self, tmp_path: Path) -> Path: + """A project file on disk, titled so a save can be told from the file as it was.""" + path = tmp_path / "song.stp" + manager = ProjectManager() + manager.current.info.title = ORIGINAL_TITLE + manager.save(path) + return path + + @pytest.fixture(name="starting") + def starting_fixture(self, tmp_path: Path) -> ProjectCoordinator: + """A coordinator over a real project manager, its session remembering nothing yet.""" + project_manager = ProjectManager() + session_manager = MagicMock() + session_manager.current_project = None + session_manager.get_project_path.return_value = tmp_path + return ProjectCoordinator( + ProjectController(project_manager), + project_manager, + session_manager, + MagicMock(), + export_backends={}, + format_setups={}, + dialogs=MagicMock(), + language_manager=MagicMock(), + on_tab_switch=MagicMock(), + on_session_state_changed=MagicMock(), + ) + + def test_the_session_remembers_its_file(self, starting: ProjectCoordinator, saved_project: Path) -> None: + starting.load_project_safely(saved_project) + + starting._session_manager.set_current_project.assert_called_once_with(saved_project) + + def test_save_writes_its_file_without_asking( self, - project_coordinator: ProjectCoordinator, + starting: ProjectCoordinator, + saved_project: Path, + save_dialog: MagicMock, ) -> None: - path = Path("song.stp") + starting.load_project_safely(saved_project) + starting._project_controller.set_title(EDITED_TITLE) - project_coordinator.load_project_safely(path) + assert starting.save() is SaveOutcome.WRITTEN - project_coordinator._project_controller.load.assert_called_once_with(path) - project_coordinator._session_manager.set_current_project.assert_not_called() + save_dialog.assert_not_called() + assert ProjectContainer.load(saved_project).info.title == EDITED_TITLE + + def test_a_new_project_asks_where_to_save( + self, + starting: ProjectCoordinator, + saved_project: Path, + save_dialog: MagicMock, + ) -> None: + starting.load_project_safely(saved_project) + starting._new() + + assert starting.save() is SaveOutcome.CALLED_OFF + + save_dialog.assert_called_once() + starting._session_manager.set_current_project.assert_called_with(None) + assert ProjectContainer.load(saved_project).info.title == ORIGINAL_TITLE class TestProjectRestoreAbsorbsFailures(BaseTestSuite): @@ -411,7 +481,7 @@ def test_a_project_with_a_file_is_written_there( tmp_path: Path, ) -> None: filepath = tmp_path / "song.stp" - saving._session_manager.current_project = filepath + saving._project_manager.path = filepath assert saving.save() is SaveOutcome.WRITTEN saving._project_controller.save.assert_called_once_with(filepath) @@ -421,7 +491,7 @@ def test_a_file_dialog_closed_without_a_name_calls_the_save_off( saving: ProjectCoordinator, save_dialog: MagicMock, ) -> None: - saving._session_manager.current_project = None + saving._project_manager.path = None assert saving.save() is SaveOutcome.CALLED_OFF saving._project_controller.save.assert_not_called() @@ -434,7 +504,7 @@ def test_a_name_chosen_in_the_file_dialog_is_written( ) -> None: filepath = tmp_path / "song.stp" save_dialog.return_value = filepath - saving._session_manager.current_project = None + saving._project_manager.path = None assert saving.save() is SaveOutcome.WRITTEN saving._project_controller.save.assert_called_once_with(filepath) @@ -445,7 +515,7 @@ def test_a_write_that_fails_shows_its_error( tmp_path: Path, ) -> None: failure = OSError("disk full") - saving._session_manager.current_project = tmp_path / "song.stp" + saving._project_manager.path = tmp_path / "song.stp" saving._project_controller.save.side_effect = failure assert saving.save() is SaveOutcome.FAILED @@ -456,7 +526,7 @@ def test_a_save_asked_for_by_itself_says_it_landed( saving: ProjectCoordinator, tmp_path: Path, ) -> None: - saving._session_manager.current_project = tmp_path / "song.stp" + saving._project_manager.path = tmp_path / "song.stp" saving.save() @@ -469,7 +539,7 @@ def test_a_save_a_prompt_asked_for_goes_on_without_a_word( ) -> None: """What the prompt guards opens next, so it opens alone.""" filepath = tmp_path / "song.stp" - saving._session_manager.current_project = filepath + saving._project_manager.path = filepath assert saving._write_project() is SaveOutcome.WRITTEN saving._project_controller.save.assert_called_once_with(filepath) diff --git a/tests/unit/sampletones_application/logic/project/test_manager.py b/tests/unit/sampletones_application/logic/project/test_manager.py index 66c243026..27d77aac5 100644 --- a/tests/unit/sampletones_application/logic/project/test_manager.py +++ b/tests/unit/sampletones_application/logic/project/test_manager.py @@ -1,9 +1,11 @@ from pathlib import Path +from typing import List, Optional import pytest from sampletones_application.logic.project.manager import ProjectManager from sampletones_core.constants.enums import ChannelName +from sampletones_core.project.project import Project from sampletones_shared.exceptions import NotAValidArchiveError from tests.suite.errors import DIRECTORY_READ_ERRORS @@ -60,3 +62,124 @@ def test_invalid_archive_raises_load_project_error(self, tmp_path: Path) -> None with pytest.raises(NotAValidArchiveError): ProjectManager().load(path) + + +class TestTheFileAProjectStandsFor: + """The open project stands for the file it was last loaded from or saved to. + + A new or closed project stands for none, a failed load leaves the open project's file as it was, + and each new file is reported once. + """ + + @pytest.fixture(name="reported") + def reported_fixture(self) -> List[Optional[Path]]: + return [] + + @pytest.fixture(name="manager") + def manager_fixture(self, reported: List[Optional[Path]]) -> ProjectManager: + manager = ProjectManager() + manager.on_path_changed = reported.append + return manager + + @pytest.fixture(name="saved_project") + def saved_project_fixture(self, tmp_path: Path) -> Path: + path = tmp_path / "song.stp" + ProjectManager().save(path) + return path + + def test_a_fresh_manager_stands_for_no_file(self, manager: ProjectManager) -> None: + assert manager.path is None + + def test_a_loaded_project_stands_for_its_file( + self, + manager: ProjectManager, + saved_project: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + + assert manager.path == saved_project + assert reported == [saved_project] + + def test_a_saved_project_stands_for_the_file_it_was_saved_to( + self, + manager: ProjectManager, + saved_project: Path, + tmp_path: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + copy = tmp_path / "copy.stp" + + manager.save(copy) + + assert manager.path == copy + assert reported == [saved_project, copy] + + def test_saving_again_to_its_file_reports_nothing_new( + self, + manager: ProjectManager, + saved_project: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + + manager.save(saved_project) + + assert reported == [saved_project] + + def test_a_new_project_stands_for_no_file( + self, + manager: ProjectManager, + saved_project: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + + manager.new() + + assert manager.path is None + assert reported == [saved_project, None] + + def test_a_closed_project_stands_for_no_file( + self, + manager: ProjectManager, + saved_project: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + + manager.close() + + assert manager.path is None + assert reported == [saved_project, None] + + def test_a_failed_load_keeps_the_open_project_s_file( + self, + manager: ProjectManager, + saved_project: Path, + tmp_path: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + broken = tmp_path / "broken.stp" + broken.write_bytes(b"this is not a zip archive") + + with pytest.raises(NotAValidArchiveError): + manager.load(broken) + + assert manager.path == saved_project + assert reported == [saved_project] + + def test_a_history_restore_keeps_the_file( + self, + manager: ProjectManager, + saved_project: Path, + reported: List[Optional[Path]], + ) -> None: + manager.load(saved_project) + + manager.install(Project.create(), clean=False) + + assert manager.path == saved_project + assert reported == [saved_project] From d730f66b52b64e16869a4f2b7603415f66d78b0b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 18:17:40 +0200 Subject: [PATCH 07/84] Fixed: leaving with a Display size never confirmed --- docs/development/bugs-and-todos.md | 4 - src/sampletones_application/application.py | 6 +- .../coordinators/display.py | 25 ++++- src/sampletones_application/viewport.py | 42 ++++++- .../display/test_kept_size_at_close.py | 7 -- .../coordinators/test_display.py | 103 ++++++++++++++++++ 6 files changed, 167 insertions(+), 20 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 2239400fe..8f5d69ee2 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -217,10 +217,6 @@ 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/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/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/closing/test_during_work.py` reproduces it as a diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 054c4fa19..c4f5cdd70 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1707,8 +1707,9 @@ def _teardown(self) -> None: def _teardown_steps(self) -> Tuple[VoidCallback, ...]: """The steps of the teardown in the order they are taken. - Background work stops before anything it reaches is let go of, the session is written while - the window it measures still stands, and the DearPyGui context goes last. + Background work stops before anything it reaches is let go of. Display settings put back what + they never confirmed before the session records the window, the session is written while the + window it measures still stands, and the DearPyGui context goes last. """ return ( release_render_thread, @@ -1718,6 +1719,7 @@ def _teardown_steps(self) -> Tuple[VoidCallback, ...]: self._playback_router.shutdown, self._main_tab.cleanup, self.library_manager.release_creator, + self._display_coordinator.cleanup, self._save_config, self._persist_application_state, self.audio_device_manager.terminate, diff --git a/src/sampletones_application/coordinators/display.py b/src/sampletones_application/coordinators/display.py index 1bc022c1f..698daf01e 100644 --- a/src/sampletones_application/coordinators/display.py +++ b/src/sampletones_application/coordinators/display.py @@ -19,7 +19,7 @@ DisplaySettingsViewModel, WindowMode, ) -from sampletones_application.viewport import ViewportManager +from sampletones_application.viewport import ViewportManager, WindowPlacement from sampletones_shared.display import Resolution @@ -29,7 +29,8 @@ class DisplayCoordinator: A change reaches the screen the moment it is made, so a user judges it by looking at it, while the session keeps the values the dialog opened with until OK commits them. Cancel re-applies - that snapshot, asking first when there is something to lose. + that snapshot, asking first when there is something to lose, and a run that ends while the + dialog stands puts it back the same way before the session is written. Changing the window's size, its frame, or fullscreen can leave the window unreadable, so each of those arms a countdown over the dialog: keeping it disarms the clock and leaves the change @@ -66,6 +67,7 @@ def __init__( self._settings: Optional[DisplaySettings] = None self._snapshot: Optional[DisplaySettings] = None + self._opening_placement: Optional[WindowPlacement] = None self._armed: Optional[WindowMode] = None self._remaining: float = 0.0 @@ -84,8 +86,26 @@ def open(self) -> None: view_model = self._view_model(self._settings_in_force()) self._snapshot = view_model.settings self._settings = view_model.settings + self._opening_placement = self._viewport_manager.placement self._window.open(view_model) + def cleanup(self) -> None: + """Puts back the display the dialog opened with when the run ends while it stands, as Cancel does. + + The session keeps the opening values until OK, and leaving records the window as it stands, so + the window returns to its opening place and size before that record is taken. A fullscreen + change reaches DearPyGui's reading of the window on a drawn frame alone, while a place and a + size set directly reach it at once, so a window that opened windowed is placed as well. + """ + snapshot = self._snapshot + placement = self._opening_placement + if snapshot is None or placement is None: + return + + self._discard() + if not snapshot.window.fullscreen: + self._viewport_manager.place(placement) + def tick(self, delta_time: float) -> None: """Advances an armed countdown, restoring the last confirmed window mode when it runs out.""" if self._armed is None: @@ -199,6 +219,7 @@ def _discard(self) -> None: def _close(self) -> None: self._settings = None self._snapshot = None + self._opening_placement = None self._window.hide() def _apply(self, previous: DisplaySettings, current: DisplaySettings) -> None: diff --git a/src/sampletones_application/viewport.py b/src/sampletones_application/viewport.py index 80c6d51ba..5abef6e47 100644 --- a/src/sampletones_application/viewport.py +++ b/src/sampletones_application/viewport.py @@ -1,4 +1,5 @@ import sys +from dataclasses import dataclass from typing import Tuple import dearpygui.dearpygui as dpg @@ -10,9 +11,19 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.monitors import MonitorArea, monitor_area_for_window from sampletones_shared.application import SAMPLETONES_NAME +from sampletones_shared.display import Resolution from sampletones_shared.types.callback import VoidCallback +@dataclass(frozen=True) +class WindowPlacement: + """Where the window stands on the desktop and the size it shows at.""" + + x: int + y: int + resolution: Resolution + + class ViewportManager: def __init__( self, @@ -77,6 +88,27 @@ def resolution(self) -> Tuple[int, int]: """The size the window is showing at right now.""" return dpg.get_viewport_width(), dpg.get_viewport_height() + @property + def placement(self) -> WindowPlacement: + """Where the window stands and the size it shows at right now.""" + viewport_x, viewport_y = dpg.get_viewport_pos() + width, height = self.resolution + return WindowPlacement( + x=int(viewport_x), + y=int(viewport_y), + resolution=Resolution(width=width, height=height), + ) + + def place(self, placement: WindowPlacement) -> None: + """Moves the window to ``placement`` and sizes it there. + + DearPyGui answers a place and a size set this way from its very next read, ahead of a drawn + frame, so a record of the window taken right after names them. + """ + dpg.set_viewport_pos([placement.x, placement.y]) + dpg.set_viewport_width(placement.resolution.width) + dpg.set_viewport_height(placement.resolution.height) + @property def monitor_area(self) -> MonitorArea: """The area of the monitor the window currently sits on, and the room it leaves a window.""" @@ -115,13 +147,13 @@ def save_window_state(self) -> None: if self._session_manager.fullscreen: return - viewport_x, viewport_y = dpg.get_viewport_pos() + placement = self.placement self._session_manager.set_window_state( fullscreen=False, - x=int(viewport_x), - y=int(viewport_y), - width=dpg.get_viewport_width(), - height=dpg.get_viewport_height(), + x=placement.x, + y=placement.y, + width=placement.resolution.width, + height=placement.resolution.height, ) def _persist_fullscreen(self, fullscreen: bool) -> 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 index 6380dfb1f..067436f99 100644 --- a/tests/screens/interface/display/test_kept_size_at_close.py +++ b/tests/screens/interface/display/test_kept_size_at_close.py @@ -1,7 +1,5 @@ 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 @@ -16,11 +14,6 @@ class TestClosingWithASizeKeptButNotConfirmed: 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]] = [] diff --git a/tests/unit/sampletones_application/coordinators/test_display.py b/tests/unit/sampletones_application/coordinators/test_display.py index ce968e408..6c2e05247 100644 --- a/tests/unit/sampletones_application/coordinators/test_display.py +++ b/tests/unit/sampletones_application/coordinators/test_display.py @@ -13,6 +13,7 @@ DisplaySettingsViewModel, WindowMode, ) +from sampletones_application.viewport import WindowPlacement from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution from sampletones_shared.types.callback import VoidCallback @@ -24,6 +25,10 @@ COUNTDOWN_SECONDS = 10.0 +OPENING_X = 120 +OPENING_Y = 90 +FULLSCREEN_PLACEMENT = WindowPlacement(x=0, y=0, resolution=Resolution(width=1920, height=1080)) + BEHAVIOR = DisplayBehavior( resolutions=( Resolution(width=1024, height=768), @@ -102,6 +107,7 @@ def __init__(self) -> None: DEFAULT_RESOLUTION.width, DEFAULT_RESOLUTION.height, ) + self.position: Tuple[int, int] = (OPENING_X, OPENING_Y) self.fullscreen_toggles = 0 self.calls: List[Tuple[str, Any]] = [] @@ -109,6 +115,17 @@ def __init__(self) -> None: def monitor_area(self) -> MonitorArea: return MonitorArea(x=0, y=0, width=1920, height=1080, usable_ratio=0.9) + @property + def placement(self) -> WindowPlacement: + width, height = self.resolution + x, y = self.position + return WindowPlacement(x=x, y=y, resolution=Resolution(width=width, height=height)) + + def place(self, placement: WindowPlacement) -> None: + self.calls.append(("place", placement)) + self.position = (placement.x, placement.y) + self.resolution = (placement.resolution.width, placement.resolution.height) + def set_resolution(self, width: int, height: int) -> None: self.calls.append(("resolution", (width, height))) self.resolution = (width, height) @@ -120,8 +137,15 @@ def set_vsync(self, vsync: bool) -> None: self.calls.append(("vsync", vsync)) def toggle_fullscreen(self) -> None: + """Answers the reads DearPyGui gives before a frame: the window keeps the fullscreen place. + + Entering fullscreen is read once a frame is drawn, and leaving it is read only on the next + frame, so a toggle back with no frame between leaves the fullscreen place standing. + """ self.fullscreen_toggles += 1 self.calls.append(("fullscreen", self.fullscreen_toggles)) + self.position = (FULLSCREEN_PLACEMENT.x, FULLSCREEN_PLACEMENT.y) + self.resolution = (FULLSCREEN_PLACEMENT.resolution.width, FULLSCREEN_PLACEMENT.resolution.height) class _FrameLimiterRecorder: @@ -250,6 +274,9 @@ def revert(self) -> None: def elapse(self, seconds: float) -> None: self.coordinator.tick(seconds) + def end_the_run(self) -> None: + self.coordinator.cleanup() + @property def settings(self) -> DisplaySettings: return self.window.settings @@ -541,3 +568,79 @@ def test_editing_a_closed_dialog_is_refused(self, harness: Harness) -> None: with pytest.raises(SystemError): harness.change(settings) + + +class TestARunEndingWhileTheDialogStands: + """A run that ends while the dialog stands puts back the display it opened with, as Cancel does. + + The session keeps the opening values until OK, and leaving records the window as it stands, so + the window returns to its opening place and size first. A change kept on the countdown is + undone the same way, and a dialog already closed leaves the display as it is. + """ + + OPENING_PLACEMENT = WindowPlacement(x=OPENING_X, y=OPENING_Y, resolution=DEFAULT_RESOLUTION) + + def test_a_size_kept_on_the_countdown_goes_back(self, harness: Harness) -> None: + harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN))) + harness.keep() + + harness.end_the_run() + + assert harness.viewport.placement == self.OPENING_PLACEMENT + + def test_fullscreen_kept_on_the_countdown_goes_back_to_the_opening_place(self, harness: Harness) -> None: + harness.change(harness.settings.with_window(harness.settings.window.with_fullscreen(True))) + harness.keep() + + harness.end_the_run() + + assert harness.viewport.fullscreen_toggles == 2 + assert harness.viewport.placement == self.OPENING_PLACEMENT + + def test_a_dialog_opened_in_fullscreen_goes_back_to_fullscreen(self) -> None: + harness = Harness() + harness.session.fullscreen = True + harness.open() + harness.change(harness.settings.with_window(harness.settings.window.with_fullscreen(False))) + harness.keep() + + harness.end_the_run() + + assert harness.viewport.fullscreen_toggles == 2 + assert all(name != "place" for name, _ in harness.viewport.calls) + + def test_the_palette_goes_back_and_nothing_is_written(self, harness: Harness) -> None: + harness.change(harness.settings.with_palette(DARK)) + + harness.end_the_run() + + assert harness.palette_source.activated == [DARK, STUDIO] + assert harness.session.writes == [] + assert not harness.window.visible + + def test_a_countdown_running_at_the_end_is_stopped(self, harness: Harness) -> None: + harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True))) + + harness.end_the_run() + + assert not harness.countdown.visible + assert harness.viewport.calls[-1] == ("place", self.OPENING_PLACEMENT) + + def test_a_confirmed_change_stays(self, harness: Harness) -> None: + harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN))) + harness.keep() + harness.commit() + calls = list(harness.viewport.calls) + + harness.end_the_run() + + assert harness.viewport.calls == calls + assert harness.viewport.resolution == (WIDESCREEN.width, WIDESCREEN.height) + + def test_a_dialog_never_opened_leaves_the_display_alone(self) -> None: + harness = Harness() + + harness.end_the_run() + + assert harness.viewport.calls == [] + assert harness.palette_source.activated == [] From 92521d144a8ba58f48b6d8daf8e5465a048f9022 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 18:22:46 +0200 Subject: [PATCH 08/84] Fixed: a Keyboard settings row reading as listening after the reassign question --- docs/development/application/keyboard.md | 2 +- docs/development/bugs-and-todos.md | 3 - .../coordinators/keybindings.py | 45 +++++++++-- .../ui/panels/dialogs/keybindings.py | 23 +++++- .../modals/test_run_ending_behind_a_dialog.py | 74 +++++++++++++------ .../coordinators/test_keybindings.py | 38 ++++++++++ .../ui/panels/dialogs/test_keybindings.py | 42 +++++++++++ 7 files changed, 193 insertions(+), 34 deletions(-) diff --git a/docs/development/application/keyboard.md b/docs/development/application/keyboard.md index 96f9d7957..6a8bb2c99 100644 --- a/docs/development/application/keyboard.md +++ b/docs/development/application/keyboard.md @@ -70,7 +70,7 @@ A change reaches the running application through `ShortcutSource.on_bindings_cha `ShortcutDraft` (`utils/gui/shortcuts/draft.py`) holds the scheme being edited together with the actions the reader has touched: the combination each was given, or nothing where it was left unbound. Only those actions reach the preference, and every other key follows the scheme beneath. -An assignment displaces. Giving an action a combination its category already answers takes the key from the holder in the same step, so every scheme a draft produces is valid. The dialog names the holder and asks before that step is taken. The dialog edits the draft, and a commit activates it, so a reader rebinding Escape, Tab or Enter keeps the keys the dialog is operated by until they are done. +An assignment displaces. Giving an action a combination its category already answers takes the key from the holder in the same step, so every scheme a draft produces is valid. The dialog names the holder and asks before that step is taken. Cancel on that question brings the reader back to where they gave the keys: a written combination to the entry box, and a pressed one to its row, listening for the next press. A row reads as listening exactly while it takes the keys. The dialog edits the draft, and a commit activates it, so a reader rebinding Escape, Tab or Enter keeps the keys the dialog is operated by until they are done. ### A scheme belongs to a platform; an action does not diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 672fda9e2..ad877a4b4 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -180,9 +180,6 @@ currently out of line. An entry leaves when the code meets the contract again. * 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/vanished/test_reconstruction_removed.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/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/closing/test_over_a_question.py` and diff --git a/src/sampletones_application/coordinators/keybindings.py b/src/sampletones_application/coordinators/keybindings.py index e0c9b42a3..174faaff5 100644 --- a/src/sampletones_application/coordinators/keybindings.py +++ b/src/sampletones_application/coordinators/keybindings.py @@ -30,6 +30,7 @@ KeybindingRow, KeybindingsViewModel, ) +from sampletones_shared.types.callback import VoidCallback NO_COMBINATION: str = "" NO_MESSAGE: str = "" @@ -45,6 +46,9 @@ class KeybindingsCoordinator: An assignment onto keys another action of the same scope holds is offered after a prompt naming that action, which is then left unbound — one combination reaches one action within a scope. + Declining the prompt brings the dialog back where the reader gave the keys: a written + combination returns to the entry box, and a pressed one returns to its row listening for the + next press. """ def __init__( @@ -115,14 +119,34 @@ def _type_combination(self, text: str) -> None: self._window.update_view(self._view_model()) return - self._assign(shortcut_id, combination) + self._assign( + shortcut_id, + combination, + on_declined=self._window.resume, + ) def _capture_combination(self, combination: KeyCombination) -> None: """Gives the selected action the keys a reader pressed.""" - self._assign(self._require_selected(), combination) + self._assign( + self._require_selected(), + combination, + on_declined=self._window.listen_again, + ) + + def _assign( + self, + shortcut_id: ShortcutId, + combination: KeyCombination, + *, + on_declined: VoidCallback, + ) -> None: + """Assigns the combination, asking first where another action of the scope holds it. - def _assign(self, shortcut_id: ShortcutId, combination: KeyCombination) -> None: - """Assigns the combination, asking first where another action of the scope holds it.""" + Args: + shortcut_id: The action the keys go to. + combination: The keys given. + on_declined: What brings the dialog back once the reader declines to take the keys. + """ draft = self._require_draft() self._message = NO_MESSAGE claimant = draft.claimant(shortcut_id, combination) @@ -130,13 +154,22 @@ def _assign(self, shortcut_id: ShortcutId, combination: KeyCombination) -> None: self._apply(draft.assign(shortcut_id, combination)) return - self._window.yield_to(lambda: self._ask_to_reassign(shortcut_id, combination, claimant)) + self._window.yield_to( + lambda: self._ask_to_reassign( + shortcut_id, + combination, + claimant, + on_declined=on_declined, + ), + ) def _ask_to_reassign( self, shortcut_id: ShortcutId, combination: KeyCombination, claimant: ShortcutId, + *, + on_declined: VoidCallback, ) -> None: message = self._template(KeybindingsElements.REASSIGN_CONFIRMATION).format( combination=combination.display(), @@ -148,7 +181,7 @@ def _ask_to_reassign( title=self._title(KeybindingsElements.REASSIGN_CONFIRMATION), message=message, on_confirm=lambda: self._reassign(shortcut_id, combination), - on_cancel=self._window.resume, + on_cancel=on_declined, ok_label=self._label(KeybindingsElements.REASSIGN_BUTTON), ) diff --git a/src/sampletones_application/ui/panels/dialogs/keybindings.py b/src/sampletones_application/ui/panels/dialogs/keybindings.py index 9047f5310..b7c1e588e 100644 --- a/src/sampletones_application/ui/panels/dialogs/keybindings.py +++ b/src/sampletones_application/ui/panels/dialogs/keybindings.py @@ -53,7 +53,9 @@ class GUIKeybindingsWindow(GUISeededDialogWindow[KeybindingsViewModel]): A row is given keys either way round: clicking its shortcut cell listens for the press to assign, and the entry box below writes a combination out for the actions a press cannot reach. Both report through their own hook, so the owner decides what an assignment means and this - window shows what it decided. + window shows what it decided. A row reads as listening exactly while the capture holds the + keyboard: the capture ends with the press it reads, and the owner listens again where the + reader declines to assign it. The action set is fixed, so the rows are built once per appearance and every later view re-reads their labels; the filter reaches the same rows through their visibility, which keeps a keystroke @@ -245,6 +247,15 @@ def _create_action_buttons(self) -> None: width=-1, ) + def listen_again(self) -> None: + """Comes back on screen with the selected row listening for the next press. + + A press the reader declined to assign leaves the row waiting for the one they meant. + """ + self.resume() + self._require_capture().start() + self._render() + def _install_capture(self) -> None: """Readies the capture that reads a press, canceled by whatever a dialog is canceled by.""" self._capture = KeyCapture( @@ -343,11 +354,17 @@ def _on_shortcut_typed(self, _sender: Sender, app_data: str) -> None: self.call(self.on_combination_typed, app_data) def _report_captured(self, combination: KeyCombination) -> None: + """Shows the row done listening, then hands the press to the owner to decide what it means.""" + self._render() self.call(self.on_combination_captured, combination) def _stop_capture(self) -> None: - if self._capture is not None: - self._capture.stop() + """Ends a capture that listens, and shows the row's keys in place of the prompt.""" + if self._capture is None or not self._capture.is_listening: + return + + self._capture.stop() + self._render() def _request_clear(self) -> None: self._stop_capture() 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 index f9f1005a3..77f3ba7ac 100644 --- a/tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py +++ b/tests/screens/prompts/modals/test_run_ending_behind_a_dialog.py @@ -1,8 +1,6 @@ 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 @@ -244,27 +242,61 @@ 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. + listening. The same keys then ask again, and Escape ends the listening with the dialog still open. """ - @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.""" + """The same keys pressed again ask the reassign question once more. + + Cancel brings the row back listening a second time, and Escape then gives it back Redo's own keys. + """ 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") + keys: Dict[ShortcutId, str] = {} + + def cancel_the_question(screen: Screen) -> None: + 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") + + reassign.cancel() + + screen.expect(settings.is_shown, bool, description="Keyboard settings back") + assert settings.keys_of(ShortcutId.REDO) == screen.words(LISTENING) + + def the_same_keys_ask_again(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.UNDO) + + screen.expect(reassign.is_shown, bool, description="the question about reassigning again") + + def escape_ends_the_listening_alone(screen: Screen) -> None: + reassign.cancel() + screen.expect(settings.is_shown, bool, description="Keyboard settings back again") + assert settings.keys_of(ShortcutId.REDO) == screen.words(LISTENING) + + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) + + screen.expect( + lambda: settings.keys_of(ShortcutId.REDO), + keys[ShortcutId.REDO].__eq__, + description="Redo's own keys", + ) + assert settings.is_shown() + assert settings.keys_of(ShortcutId.UNDO) == keys[ShortcutId.UNDO] + + def cancel_closes_the_dialog(screen: Screen) -> None: + settings.cancel() + + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + + screen.scenario( + cancel_the_question, + the_same_keys_ask_again, + escape_ends_the_listening_alone, + cancel_closes_the_dialog, + ).run() diff --git a/tests/unit/sampletones_application/coordinators/test_keybindings.py b/tests/unit/sampletones_application/coordinators/test_keybindings.py index c9434fbee..ea4eb89c8 100644 --- a/tests/unit/sampletones_application/coordinators/test_keybindings.py +++ b/tests/unit/sampletones_application/coordinators/test_keybindings.py @@ -61,6 +61,7 @@ class _WindowRecorder: def __init__(self) -> None: self.view_models: List[KeybindingsViewModel] = [] self.visible = False + self.listening = False self.on_scheme_selected: Any = None self.on_action_selected: Any = None self.on_combination_typed: Any = None @@ -84,6 +85,10 @@ def yield_to(self, raise_modal: VoidCallback) -> None: def resume(self) -> None: self.visible = True + def listen_again(self) -> None: + self.visible = True + self.listening = True + def hide(self) -> None: self.visible = False @@ -298,6 +303,39 @@ def test_declining_leaves_both_actions_on_the_keys_they_had(self, harness: Harne assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION assert harness.window.visible + def test_declining_a_pressed_combination_brings_its_row_back_listening(self, harness: Harness) -> None: + """The reader declined the keys they pressed, so the row waits for the press they meant.""" + harness.select(ABOUT_DIALOG) + harness.capture(SAVE_COMBINATION) + harness.dialogs.decline() + + assert harness.window.visible + assert harness.window.listening + assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION + + def test_declining_a_written_combination_brings_the_dialog_back_listening_for_nothing( + self, + harness: Harness, + ) -> None: + harness.select(ABOUT_DIALOG) + harness.type_combination(SAVE_COMBINATION) + harness.dialogs.decline() + + assert harness.window.visible + assert not harness.window.listening + + def test_confirming_a_pressed_combination_brings_the_dialog_back_listening_for_nothing( + self, + harness: Harness, + ) -> None: + harness.select(ABOUT_DIALOG) + harness.capture(SAVE_COMBINATION) + harness.dialogs.confirm() + + assert harness.window.visible + assert not harness.window.listening + assert harness.row(ABOUT_DIALOG).combination == SAVE_COMBINATION + def test_an_alias_another_action_answers_is_taken_the_same_way(self, harness: Harness) -> None: """Redo answers Ctrl+Shift+Z beside its own keys, which an assignment takes with them.""" harness.select(ABOUT_DIALOG) diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py index f175443f9..4486f714c 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py @@ -320,6 +320,28 @@ def test_a_canceled_capture_leaves_the_cell_reading_its_keys(self, harness: Harn assert harness.captured == [] assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == "Ctrl+S" + def test_a_captured_press_leaves_the_cell_reading_its_keys(self, harness: Harness) -> None: + """The capture ends with the press it reads, so the cell stops asking for one.""" + harness.render(view_model(selected=SAVE_PROJECT)) + harness.click_shortcut(SAVE_PROJECT) + harness.press(dpg.mvKey_G, CTRL_ALT) + + assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == "Ctrl+S" + + def test_listening_again_asks_for_a_press_and_takes_the_next_one(self, harness: Harness) -> None: + harness.render(view_model(selected=SAVE_PROJECT)) + harness.click_shortcut(SAVE_PROJECT) + harness.press(dpg.mvKey_G, CTRL_ALT) + + harness.window.listen_again() + + assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == CAPTURING_MESSAGE + harness.press(dpg.mvKey_B, CTRL_ALT) + assert harness.captured == [ + KeyCombination(dpg.mvKey_G, CTRL_ALT), + KeyCombination(dpg.mvKey_B, CTRL_ALT), + ] + def test_clicking_an_action_listens_for_nothing(self, harness: Harness) -> None: """The name cell selects the row, which leaves the keyboard where it was.""" harness.render(view_model(selected=SAVE_PROJECT)) @@ -381,3 +403,23 @@ def test_a_button_pressed_mid_capture_stops_listening(self, harness: Harness) -> harness.press(dpg.mvKey_G, CTRL) assert harness.captured == [] + + @pytest.mark.parametrize( + "tag", + [ + TAG_SETTINGS_KEYBINDINGS_BUTTON_RESET, + TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, + ], + ids=["reset", "cancel"], + ) + def test_a_button_pressed_mid_capture_leaves_the_cell_reading_its_keys( + self, + harness: Harness, + tag: str, + ) -> None: + """Reset and Cancel ask a question first, and the row behind it reads what it holds.""" + harness.render(view_model(selected=SAVE_PROJECT)) + harness.click_shortcut(SAVE_PROJECT) + harness.press_button(tag) + + assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == "Ctrl+S" From 1b25ccfff8d88e95d1d82d01bb9f8b922560c1eb Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 18:24:13 +0200 Subject: [PATCH 09/84] Fixed: a box clicked in the Converter list picks its row --- docs/development/bugs-and-todos.md | 3 - .../logic/main/converter/logic.py | 18 +++-- tests/screens/main/row_settings/test_boxes.py | 7 -- .../logic/main/converter/test_logic.py | 81 +++++++++++++++++++ 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index f987c5557..bbc4bb002 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -159,9 +159,6 @@ currently out of line. An entry leaves when the code meets the contract again. 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/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/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/cards/test_card_layout.py` reproduces it as a known failure. diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 420f35261..bfbd3fff5 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -306,18 +306,24 @@ def toggle_channel(self, channel_name: ChannelName) -> None: self.toggle_slot(SettingsField.CHANNELS, channel_name) def set_source_channels(self, path: Path, channels: FrozenSet[ChannelName]) -> None: - """Names the channels one recording may take, which is the whole of what it reaches.""" + """Names the channels one recording may take, and picks its row out. + + A click on a row always picks it, the boxes beside its name included, so the settings + card follows the row whose box moved. + """ gathering = self._state.gathering.written(path, CHANNEL_SLOT, channels) - self._rewrite(self._state.with_gathering(gathering)) + self._rewrite(self._state.with_gathering(gathering).with_selected(SourceKey.recording(path))) def toggle_folder_channel(self, root: Path, channel_name: ChannelName) -> None: - """Settles one channel on every recording a folder stands for, in one gesture. + """Settles one channel on every recording a folder stands for, and picks the folder out. A folder its recordings already agree on lets the channel go; every other reading settles - the whole folder on it, so one gesture always moves the group somewhere. + the whole folder on it, so one gesture always moves the group somewhere. The click picks + the folder's row, as a click on its name does. """ - gathering = self._state.gathering.toggled(SourceKey.folder(root), CHANNEL_SLOT, channel_name) - self._rewrite(self._state.with_gathering(gathering)) + folder = SourceKey.folder(root) + gathering = self._state.gathering.toggled(folder, CHANNEL_SLOT, channel_name) + self._rewrite(self._state.with_gathering(gathering).with_selected(folder)) def move_source_within_level(self, path: Path, offset: int) -> None: """Moves a recording past the neighbor it shares a level with.""" diff --git a/tests/screens/main/row_settings/test_boxes.py b/tests/screens/main/row_settings/test_boxes.py index 7cbfc9b97..274e4d3ac 100644 --- a/tests/screens/main/row_settings/test_boxes.py +++ b/tests/screens/main/row_settings/test_boxes.py @@ -1,8 +1,6 @@ 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 @@ -70,11 +68,6 @@ class TestTheCardFollowsABoxClicked: 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 diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index 75227b260..4d974005e 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -1029,6 +1029,87 @@ def test_turning_to_a_mix_gives_up_the_folder( assert [row.stands_for_a_folder for row in rows] == [False, False] +class TestABoxPicksItsRow(BaseTestSuite): + """A click on a row's box picks that row, as a click on its name does, so the card follows the box.""" + + @staticmethod + def _folder(converter_logic: ConverterLogic, tmp_path: Path) -> Path: + root = tmp_path / "sources" + root.mkdir() + for name in ("a.wav", "b.wav"): + (root / name).touch() + + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + return root + + def test_a_recordings_box_picks_the_recording(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick", "snare") + snare = Path("/audio/snare.wav") + converter_logic.select_row(Path("/audio/kick.wav"), SourceKind.RECORDING) + + converter_logic.set_source_channels(snare, frozenset({ChannelName.PULSE1})) + + subject = converter_logic.source_settings_view.subject + assert subject is not None + assert (_view(converter_logic).selected_key, subject.name, subject.kind) == ( + str(snare), + snare.stem, + SourceKind.RECORDING, + ) + + def test_a_box_with_nothing_picked_picks_its_row(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick") + kick = Path("/audio/kick.wav") + assert converter_logic.source_settings_view.edits_new_recordings is True + + converter_logic.set_source_channels(kick, frozenset({ChannelName.PULSE1})) + + assert converter_logic.source_settings_view.edits_new_recordings is False + assert _view(converter_logic).selected_key == str(kick) + + def test_the_row_picked_before_keeps_its_channels(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick", "snare") + kick = Path("/audio/kick.wav") + converter_logic.select_row(kick, SourceKind.RECORDING) + standing = _card_channels(converter_logic) + + converter_logic.set_source_channels(Path("/audio/snare.wav"), frozenset({ChannelName.NOISE})) + converter_logic.select_row(kick, SourceKind.RECORDING) + + assert _card_channels(converter_logic) == standing + + def test_a_box_inside_a_folder_picks_that_recording( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path) + converter_logic.select_row(root, SourceKind.FOLDER) + + converter_logic.set_source_channels(root / "a.wav", frozenset({ChannelName.PULSE1})) + + assert _view(converter_logic).selected_key == str(root / "a.wav") + assert _card_channels(converter_logic) == frozenset({ChannelName.PULSE1}) + + def test_a_folders_box_picks_the_folder( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path) + converter_logic.select_row(root / "a.wav", SourceKind.RECORDING) + + converter_logic.toggle_folder_channel(root, ChannelName.TRIANGLE) + + subject = converter_logic.source_settings_view.subject + assert subject is not None + assert (_view(converter_logic).selected_key, subject.name, subject.kind) == ( + str(root), + root.name, + SourceKind.FOLDER, + ) + + class TestTheChannelAKeyReaches(BaseTestSuite): """A channel's key settles the row a reader picked out, which is the box beside that row.""" From 10175c20bfd342689ec59df031821744cceddea1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 18:28:58 +0200 Subject: [PATCH 10/84] Fixed: General settings filling its row while Advanced settings is put away --- docs/development/bugs-and-todos.md | 3 - .../coordinators/tabs/main.py | 2 +- .../ui/elements/layout/columns.py | 29 ++++----- tests/screens/main/cards/test_card_layout.py | 5 -- .../sampletones_application/test_startup.py | 26 ++++---- .../ui/elements/layout/test_columns.py | 65 +++++++++++++------ 6 files changed, 74 insertions(+), 56 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index bbc4bb002..78a836f30 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -159,9 +159,6 @@ currently out of line. An entry leaves when the code meets the contract again. 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/scan/test_reading_a_folder.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/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/run/test_destination.py` reproduces it as diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 8aa150f59..5c1305f67 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -825,7 +825,7 @@ def _sync_advanced_settings(self) -> None: cells.add(TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL) self._advanced_settings_panel.set_visibility(standing) - TabColumns.stand_columns(self._config_columns, cells, self._geometry.panel_gap) + TabColumns.stand_columns(self._config_columns, cells) self._sync_config_row_height() def emit_initial_view(self) -> None: diff --git a/src/sampletones_application/ui/elements/layout/columns.py b/src/sampletones_application/ui/elements/layout/columns.py index 9e9d43db6..bb6f74e2a 100644 --- a/src/sampletones_application/ui/elements/layout/columns.py +++ b/src/sampletones_application/ui/elements/layout/columns.py @@ -15,7 +15,6 @@ from sampletones_shared.types.callback import StringCallback _STRETCH_WEIGHT: Final[float] = 1.0 -_PUT_AWAY: Final[float] = 0.0 @dataclass(frozen=True) @@ -132,33 +131,29 @@ def row( cls._bind_column_themes(columns) - @classmethod + @staticmethod def stand_columns( - cls, columns: Sequence[ColumnSpec], standing: AbstractSet[str], - panel_gap: int, ) -> None: """Divides a row built by :meth:`row` among the columns in ``standing``. - A card the reader puts away leaves its column with nothing to hold, so the column drops to - no width and the ones still standing divide the whole row between them. A gap holds its - width where a column stands on each side of it, so what is left sits flush to the row's - edges and keeps one gap between neighbors. A column comes back at the size it was declared - with. + A card the reader puts away leaves its column with nothing to hold, so the column is + disabled and the ones still standing divide the whole row between them, its padding + included. A gap stands where a column stands on each side of it, so what is left sits flush + to the row's edges and keeps one gap between neighbors. A column comes back at the size it + was declared with. + + Disabling is what lets a column go entirely: DearPyGui reads a weight of zero as the + default share, and the smallest positive weight still keeps a few pixels and the cell's + padding. """ preceded = False for index, column in enumerate(columns): stands = column.tag in standing - dpg_configure_item( - compose_tag(column.tag, SUF_TABLE_COLUMN), - init_width_or_weight=column.declared_size if stands else _PUT_AWAY, - ) + dpg_configure_item(compose_tag(column.tag, SUF_TABLE_COLUMN), enabled=stands) if index > 0: - dpg_configure_item( - compose_tag(column.tag, SUF_TABLE_GAP), - init_width_or_weight=panel_gap if stands and preceded else _PUT_AWAY, - ) + dpg_configure_item(compose_tag(column.tag, SUF_TABLE_GAP), enabled=stands and preceded) preceded = preceded or stands diff --git a/tests/screens/main/cards/test_card_layout.py b/tests/screens/main/cards/test_card_layout.py index 856ac4fd8..937a3c503 100644 --- a/tests/screens/main/cards/test_card_layout.py +++ b/tests/screens/main/cards/test_card_layout.py @@ -102,11 +102,6 @@ def the_two_side_by_side(screen: Screen) -> None: 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: """With Advanced put away, General ends at the Converter's right edge.""" screen.press_shortcut(ShortcutId.TOGGLE_ADVANCED_SETTINGS) diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 2df2f3ca7..f6b67f5de 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -1142,28 +1142,32 @@ def test_the_row_holds_its_height_until_both_cards_collapse(self, app: Applicati assert dpg.get_item_configuration(TAG_MAIN_CONFIG_TABLE_CONFIG_ROW)["height"] == 0 @staticmethod - def _share(cell_tag: str) -> float: - """The share of the settings row the column behind a cell holds.""" - column = compose_tag(cell_tag, SUF_TABLE_COLUMN) - return float(dpg.get_item_configuration(column)["init_width_or_weight"]) + def _column(cell_tag: str) -> Dict[str, Any]: + """The configuration of the settings row's column behind a cell.""" + configuration: Dict[str, Any] = dpg.get_item_configuration(compose_tag(cell_tag, SUF_TABLE_COLUMN)) + return configuration def test_the_advanced_card_leaves_the_row_and_comes_back_to_its_half(self, app: Application) -> None: """One toggle leaves the row to the general card, the other gives the advanced one its half. - Which way the first toggle goes is whatever the session was left at, so the pair of shares - is what the rule states: nothing while the card is put away, and the general card's own - share once it stands again. + Which way the first toggle goes is whatever the session was left at, so the pair of readings + is what the rule states: the column leaves the row while the card is put away, and stands + again at the general card's own share. The general card stands throughout. """ coordinator = app._main_tab - general = self._share(TAG_MAIN_CONFIG_PANEL_CONFIG_CELL) + general = self._column(TAG_MAIN_CONFIG_PANEL_CONFIG_CELL)["init_width_or_weight"] coordinator.toggle_advanced_settings() - first = self._share(TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL) + first = self._column(TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL) + first_general = self._column(TAG_MAIN_CONFIG_PANEL_CONFIG_CELL)["enabled"] coordinator.toggle_advanced_settings() - second = self._share(TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL) + second = self._column(TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL) + second_general = self._column(TAG_MAIN_CONFIG_PANEL_CONFIG_CELL)["enabled"] assert general > 0 - assert {first, second} == {0.0, general} + assert {first["enabled"], second["enabled"]} == {False, True} + assert (first["init_width_or_weight"], second["init_width_or_weight"]) == (general, general) + assert (first_general, second_general) == (True, True) class TestBrowserGathering: diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_columns.py b/tests/unit/sampletones_application/ui/elements/layout/test_columns.py index 33fa0892c..b703868b2 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_columns.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_columns.py @@ -41,6 +41,14 @@ def _gap(tag: str) -> float: return float(dpg.get_item_configuration(compose_tag(tag, SUF_TABLE_GAP))["init_width_or_weight"]) +def _stands(tag: str) -> bool: + return bool(dpg.get_item_configuration(compose_tag(tag, SUF_TABLE_COLUMN))["enabled"]) + + +def _gap_stands(tag: str) -> bool: + return bool(dpg.get_item_configuration(compose_tag(tag, SUF_TABLE_GAP))["enabled"]) + + class TestARowDeclaresWhatItsColumnsTake(BaseTestSuite): """A row states each column's share rather than reading it back from the card inside it, so the proportions hold whatever its cards draw and a column put away can come back at the share it @@ -73,51 +81,58 @@ def test_a_gap_stands_between_neighbors(self, dpg_context: None) -> None: class TestARowDividesItselfAmongTheColumnsStanding(BaseTestSuite): - """A card the reader puts away leaves its column nothing to hold, so the row gives its share to - the columns still standing and keeps one gap between each of them.""" + """A card the reader puts away leaves its column nothing to hold, so the column leaves the row + and the columns still standing divide it, keeping one gap between each of them.""" @dataclass(frozen=True, kw_only=True) class StandingCase(BaseRegularTestCase): declared: Tuple[str, ...] standing: Tuple[str, ...] - expected_weights: Tuple[float, ...] - expected_gaps: Tuple[float, ...] + expected_columns: Tuple[bool, ...] + expected_gaps: Tuple[bool, ...] test_cases = ( StandingCase( label="both_stand", declared=(_LEFT, _RIGHT), standing=(_LEFT, _RIGHT), - expected_weights=(1.0, 1.0), - expected_gaps=(_PANEL_GAP,), + expected_columns=(True, True), + expected_gaps=(True,), ), StandingCase( label="the_last_is_put_away", declared=(_LEFT, _RIGHT), standing=(_LEFT,), - expected_weights=(1.0, 0.0), - expected_gaps=(0.0,), + expected_columns=(True, False), + expected_gaps=(False,), ), StandingCase( label="the_first_is_put_away", declared=(_LEFT, _RIGHT), standing=(_RIGHT,), - expected_weights=(0.0, 1.0), - expected_gaps=(0.0,), + expected_columns=(False, True), + expected_gaps=(False,), ), StandingCase( label="the_middle_is_put_away", declared=(_LEFT, _MIDDLE, _RIGHT), standing=(_LEFT, _RIGHT), - expected_weights=(1.0, 0.0, 1.0), - expected_gaps=(0.0, _PANEL_GAP), + expected_columns=(True, False, True), + expected_gaps=(False, True), + ), + StandingCase( + label="the_first_two_are_put_away", + declared=(_LEFT, _MIDDLE, _RIGHT), + standing=(_RIGHT,), + expected_columns=(False, False, True), + expected_gaps=(False, False), ), StandingCase( label="every_column_is_put_away", declared=(_LEFT, _RIGHT), standing=(), - expected_weights=(0.0, 0.0), - expected_gaps=(0.0,), + expected_columns=(False, False), + expected_gaps=(False,), ), ) @@ -127,18 +142,30 @@ def test_the_row_holds_what_stands(self, case: StandingCase, dpg_context: None) with dpg.window(): TabColumns.row(panel_gap=_PANEL_GAP, columns=columns) - TabColumns.stand_columns(columns, frozenset(case.standing), _PANEL_GAP) + TabColumns.stand_columns(columns, frozenset(case.standing)) + + assert tuple(_stands(tag) for tag in case.declared) == case.expected_columns + assert tuple(_gap_stands(tag) for tag in case.declared[1:]) == case.expected_gaps + + def test_a_column_put_away_keeps_the_share_it_was_declared_with(self, dpg_context: None) -> None: + """The share stays declared while the column is away, so what stands divides the row by theirs.""" + columns = _columns(_LEFT, _RIGHT) + with dpg.window(): + TabColumns.row(panel_gap=_PANEL_GAP, columns=columns) + declared = (_weight(_LEFT), _weight(_RIGHT), _gap(_RIGHT)) + + TabColumns.stand_columns(columns, frozenset({_LEFT})) - assert tuple(_weight(tag) for tag in case.declared) == case.expected_weights - assert tuple(_gap(tag) for tag in case.declared[1:]) == case.expected_gaps + assert (_weight(_LEFT), _weight(_RIGHT), _gap(_RIGHT)) == declared def test_a_column_comes_back_at_the_share_it_was_declared_with(self, dpg_context: None) -> None: columns = _columns(_LEFT, _RIGHT) with dpg.window(): TabColumns.row(panel_gap=_PANEL_GAP, columns=columns) - TabColumns.stand_columns(columns, frozenset({_LEFT}), _PANEL_GAP) - TabColumns.stand_columns(columns, frozenset({_LEFT, _RIGHT}), _PANEL_GAP) + TabColumns.stand_columns(columns, frozenset({_LEFT})) + TabColumns.stand_columns(columns, frozenset({_LEFT, _RIGHT})) + assert (_stands(_LEFT), _stands(_RIGHT), _gap_stands(_RIGHT)) == (True, True, True) assert _weight(_LEFT) == _weight(_RIGHT) assert _gap(_RIGHT) == _PANEL_GAP From 3b302be12811153808b89372245012bbd56027a8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 18:40:00 +0200 Subject: [PATCH 11/84] Fixed: the Playback menu missing the song's transitions --- docs/development/application/playback.md | 2 + docs/development/bugs-and-todos.md | 3 - src/sampletones_application/application.py | 5 +- .../tabs/sequencer/coordinator.py | 3 + .../logic/sequencer/playback/song_player.py | 22 +++++ tests/screens/sequencer/tracker/constants.py | 1 + .../sequencer/tracker/test_notes_typed.py | 81 ++++++++++++---- .../sequencer/playback/test_song_player.py | 92 ++++++++++++++++++- 8 files changed, 185 insertions(+), 24 deletions(-) diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md index 11b4e37c0..82080f165 100644 --- a/docs/development/application/playback.md +++ b/docs/development/application/playback.md @@ -72,6 +72,8 @@ Because the target prefers the active tab's own source, Play/Pause acts on what The toolbar's transport strip and the Playback menu describe the target. The Play/Pause/Resume label and the paused indicator report what the toggle will do. Stop is available while any sound is on the device, engaged or previewed. The display therefore carries across tabs that play nothing of their own, showing the source sounding elsewhere, and shows the local source on tabs that have one. A verb tied to one screen, such as playing from the shown frame, is offered on that screen with its document open. +**A source reports its own transitions.** Each source tells the surfaces when it starts, pauses, resumes, stops or ends, whichever gesture moved it: the transport, a context menu, a key a grid owns, or the end of the sound. The surfaces therefore stay in step with a source no caller remembered to mention. A preview belongs to no source, so the surfaces read the device for it. + The sequencer view reports the playhead too, at the reach the **follow mode** chooses: the sounding row, the frame that holds it, or the view the user placed. The mode is one setting with two derived answers: whether the tracker shows the frame being played, and whether it scrolls to keep the sounding row in sight. Those two are its whole contract, and every surface that follows the playhead reads them. The song player holds the mode and emits it with every position, so the menu's check and the grid's scrolling settle in one step when the mode changes mid-playback. A mark belongs to what it names. The playhead's position is a frame and a row within it. The order grid marks the frame under every mode. The row's mark reads as the sounding row of the pattern on screen: the tracker carries it while the frame it shows is the frame that sounds, and the mark travels with the frame across a structural order edit. Every mode paints on this rule, and the mode governs where the view sits. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index ad877a4b4..6d4fcfcf8 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -187,9 +187,6 @@ currently out of line. An entry leaves when the code meets the contract again. * 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/player/test_source_switch.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/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/voices/test_voices_card.py` reproduces it as a known failure. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 7b471834d..8111049ed 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -589,6 +589,7 @@ def __init__( on_tab_switch=self._set_current_tab, on_nes_frequency_changed=self._retune_samples_for_rate, on_channels_changed=self._update_menu, + on_audio_state_changed=self._update_menu, after_edits=self._reconstruction_coordinator.after_edits, ) @@ -1564,11 +1565,9 @@ def _import_instrument(self) -> None: def _play_from_start(self) -> None: self._playback_router.play_from_start() - self._update_menu() def _play(self) -> None: self._playback_router.play() - self._update_menu() def _play_from_frame(self) -> None: """Plays from the current order frame; available only in the Sequencer tab.""" @@ -1576,11 +1575,9 @@ def _play_from_frame(self) -> None: return self._sequencer_tab.play_from_current_frame() - self._update_menu() def _stop(self) -> None: self._playback_router.stop() - self._update_menu() def _toggle_channel(self, generator: ChannelName) -> None: """Switches one NES channel in the tab in front of the reader. diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index c85cc1031..44bc4af49 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -115,6 +115,7 @@ def __init__( on_tab_switch: Callable[[Tab], None], on_nes_frequency_changed: Callable[[int], None], on_channels_changed: VoidCallback, + on_audio_state_changed: VoidCallback, after_edits: Gate, ) -> None: self._project_controller = project_controller @@ -128,6 +129,7 @@ def __init__( self._on_tab_switch = on_tab_switch self._on_nes_frequency_changed = on_nes_frequency_changed self._on_channels_changed = on_channels_changed + self._on_audio_state_changed = on_audio_state_changed self._after_edits = after_edits self._language_manager = language_manager self._dialogs = dialogs @@ -603,6 +605,7 @@ def _wire_playback_callbacks(self) -> None: self._song_player_logic.on_position_changed = self._on_player_position_changed self._song_player_logic.on_view_changed = self._on_player_view_changed self._song_player_logic.on_error = self._on_player_error + self._song_player_logic.on_change_audio_state = self._on_audio_state_changed def _wire_project_callbacks(self) -> None: self._project_controller.on_settings_changed = self._sequencer_tracker_logic.push_settings diff --git a/src/sampletones_application/logic/sequencer/playback/song_player.py b/src/sampletones_application/logic/sequencer/playback/song_player.py index 6681c4525..b4d289807 100644 --- a/src/sampletones_application/logic/sequencer/playback/song_player.py +++ b/src/sampletones_application/logic/sequencer/playback/song_player.py @@ -13,6 +13,7 @@ from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel from sampletones_core.audio import AudioDeviceManager from sampletones_core.project.song_position import SongPosition +from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -57,6 +58,10 @@ class SongPlayerLogic(CallbackMixin): All playback controls are forwarded here from the UI panel. Position updates are forwarded to the coordinator via callbacks. + + Every transition of the playback reaches ``on_change_audio_state``: a start, a pause, a resume, + a stop, the song ending by itself and a playback error. Whoever shows the transport follows the + song through that one hook, whichever gesture moved it. Position updates leave the hook quiet. """ def __init__( @@ -81,6 +86,7 @@ def __init__( self.on_position_changed: Optional[Callable[[int, int], None]] = None self.on_view_changed: Optional[Callable[[SongPlayerViewModel], None]] = None self.on_error: Optional[Callable[[Exception], None]] = None + self.on_change_audio_state: Optional[VoidCallback] = None def play(self) -> None: self._last_error = None @@ -100,6 +106,7 @@ def _start_from(self, position: SongPosition) -> None: row_index=position.row_index, ) self._emit_view() + self._notify_audio_state_changed() def pause_or_resume(self) -> None: if self._service.is_paused: @@ -108,8 +115,10 @@ def pause_or_resume(self) -> None: self._service.pause() else: self.play() + return self._emit_view() + self._notify_audio_state_changed() def seek(self, order_position: int) -> None: """Moves the live playhead to another order, preserving sounding voices. @@ -137,10 +146,18 @@ def relocate(self, order_position: int) -> None: self._awaiting_seek_order = order_position def stop(self) -> None: + """Silences the song and puts the playhead back at its start. + + A stop of a song already silent is no transition, so it leaves ``on_change_audio_state`` + quiet. + """ + was_engaged = self._service.alive self._service.stop() self._position = SongPosition() self._awaiting_seek_order = None self._emit_view() + if was_engaged: + self._notify_audio_state_changed() def is_playing(self) -> bool: return self._service.is_playing @@ -192,12 +209,17 @@ def _on_service_result(self, result: SongPlayerResult) -> None: self._position = SongPosition() self._awaiting_seek_order = None self._emit_idle_view() + self._notify_audio_state_changed() case SongPlaybackError(error=error): self._last_error = str(error) if str(error) else type(error).__name__ self._position = SongPosition() self._awaiting_seek_order = None self.call(self.on_error, error) self._emit_view() + self._notify_audio_state_changed() + + def _notify_audio_state_changed(self) -> None: + self.call(self.on_change_audio_state) def _emit_view(self) -> None: self.call( diff --git a/tests/screens/sequencer/tracker/constants.py b/tests/screens/sequencer/tracker/constants.py index e91590f8a..407f2db82 100644 --- a/tests/screens/sequencer/tracker/constants.py +++ b/tests/screens/sequencer/tracker/constants.py @@ -8,3 +8,4 @@ LINE_NUMBER: Final[str] = "00" PAD_NUMBER: Final[str] = "02" SAMPLE_COLUMN: Final[Optional[ChannelName]] = None +PLAY_FROM_THIS_FRAME: Final[str] = "sequencer.tracker.label.context_play_from_frame" diff --git a/tests/screens/sequencer/tracker/test_notes_typed.py b/tests/screens/sequencer/tracker/test_notes_typed.py index 443030b16..0a313219e 100644 --- a/tests/screens/sequencer/tracker/test_notes_typed.py +++ b/tests/screens/sequencer/tracker/test_notes_typed.py @@ -1,20 +1,26 @@ +import operator 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.constants import ( + LINE_NUMBER, + PAD_NUMBER, + PIANO_C, + PLAY_FROM_THIS_FRAME, + 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.vocabulary.playback import PAUSE, PLAY from tests.suite.screens.worlds.songs import PAD_ROW PIANO_C_UP: Final[int] = IMGUI_LETTER_A + ord("q") - ord("a") @@ -174,20 +180,63 @@ def an_instrument_in_the_sample_column_changes_nothing(screen: Screen) -> None: ).run() +def reads_as_playing(screen: Screen) -> None: + """Expects the Playback menu to offer Pause, with Stop answering.""" + playback = screen.sequencer.playback + screen.expect(playback.can_stop, bool, description="Stop answering") + assert playback.play_entry() == screen.words(PAUSE) + + +def reads_as_stopped(screen: Screen) -> None: + """Expects the Playback menu to offer Play, with Stop greyed out.""" + playback = screen.sequencer.playback + screen.expect(lambda: not playback.can_stop(), bool, description="Stop greyed out") + assert playback.play_entry() == screen.words(PLAY) + + class TestPlayingFromATrackerRow: - """Playing the song from the caret's row reads as playing in the Playback menu, Stop answering.""" + """Playing the song from the tracker reads as playing in the Playback menu, Stop answering, whichever way it + started, and stopping it reads as stopped again. + """ - @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) + """The play-from-row shortcut and the cell menu's Play from this frame each turn the menu to Pause, and + Stop from the menu and from its key each turn it back to Play. + """ + tracker = screen.sequencer.tracker + menu = screen.context_menu + + def the_shortcut_plays_from_the_row(screen: Screen) -> None: + on_the_sequencer(screen) + tracker.click(1, ChannelName.PULSE1, SubColumn.VOICE) + reads_as_stopped(screen) + + screen.press_shortcut(ShortcutId.TRACKER_PLAY_FROM_ROW) - screen.press_shortcut(ShortcutId.TRACKER_PLAY_FROM_ROW) + reads_as_playing(screen) - screen.expect(screen.sequencer.playback.can_stop, bool, description="Stop answering") - assert screen.sequencer.playback.play_entry() == screen.words(PAUSE) + def stop_from_the_menu_reads_as_stopped(screen: Screen) -> None: + screen.sequencer.playback.stop() + + reads_as_stopped(screen) + + def the_cell_menu_plays_from_the_frame(screen: Screen) -> None: + tracker.right_click(1, ChannelName.PULSE1, SubColumn.VOICE) + screen.expect(menu.is_shown, bool, description="the cell's menu") + + menu.choose(screen.words(PLAY_FROM_THIS_FRAME)) + + screen.expect(menu.is_shown, operator.not_, description="the menu answered") + reads_as_playing(screen) + + def the_stop_key_reads_as_stopped(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.STOP) + + reads_as_stopped(screen) + + screen.scenario( + the_shortcut_plays_from_the_row, + stop_from_the_menu_reads_as_stopped, + the_cell_menu_plays_from_the_frame, + the_stop_key_reads_as_stopped, + ).run() diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_player.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_player.py index c6cfc27a5..0db6536ff 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_player.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_player.py @@ -1,4 +1,5 @@ -from typing import List, Tuple +from dataclasses import dataclass +from typing import Callable, List, Tuple from unittest.mock import MagicMock import pytest @@ -12,6 +13,8 @@ ) from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel from sampletones_core.project.song_position import SongPosition +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( make_controller, ) @@ -315,6 +318,93 @@ def test_playback_error_fires_on_error_callback(self) -> None: assert errors == [error] +class TestTheTransitionHook(BaseTestSuite): + """Every transition of the song reaches ``on_change_audio_state`` once, and nothing else does. + + The menu and the toolbar follow the song through that hook, whichever gesture moved it. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + gesture: Callable[[SongPlayerLogic], None] + is_open: bool = True + playing: bool = False + paused: bool = False + expected: int + + test_cases = ( + TestCase(label="play", gesture=SongPlayerLogic.play, expected=1), + TestCase(label="play with no project", gesture=SongPlayerLogic.play, is_open=False, expected=0), + TestCase(label="play from a row", gesture=lambda logic: logic.play_from(1, 4), expected=1), + TestCase(label="the toggle starting", gesture=SongPlayerLogic.pause_or_resume, expected=1), + TestCase(label="pause", gesture=SongPlayerLogic.pause_or_resume, playing=True, expected=1), + TestCase(label="resume", gesture=SongPlayerLogic.pause_or_resume, paused=True, expected=1), + TestCase(label="stop while playing", gesture=SongPlayerLogic.stop, playing=True, expected=1), + TestCase(label="stop while paused", gesture=SongPlayerLogic.stop, paused=True, expected=1), + TestCase(label="stop of a silent song", gesture=SongPlayerLogic.stop, expected=0), + TestCase( + label="the song ending by itself", + gesture=lambda logic: logic._on_service_result(SongPlaybackStopped()), + playing=True, + expected=1, + ), + TestCase( + label="a playback error", + gesture=lambda logic: logic._on_service_result(SongPlaybackError(error=RuntimeError("device lost"))), + playing=True, + expected=1, + ), + TestCase( + label="a row played", + gesture=lambda logic: logic._on_service_result( + SongPositionUpdate(position=SongPosition(order_position=0, row_index=1)) + ), + playing=True, + expected=0, + ), + TestCase(label="a seek while playing", gesture=lambda logic: logic.seek(2), playing=True, expected=0), + TestCase( + label="a follow mode chosen", + gesture=lambda logic: logic.set_follow_mode(FollowMode.PATTERNS), + playing=True, + expected=0, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_transitions_reach_the_hook(self, test_case: TestCase) -> None: + logic = _make_logic(is_open=test_case.is_open) + logic._service.is_playing = test_case.playing + logic._service.is_paused = test_case.paused + logic._service.alive = test_case.playing or test_case.paused + logic.on_view_changed = lambda _view: None + logic.on_position_changed = lambda _order, _row: None + logic.on_error = lambda _error: None + transitions: List[None] = [] + logic.on_change_audio_state = lambda: transitions.append(None) + + test_case.gesture(logic) + + assert len(transitions) == test_case.expected + + def test_the_hook_reads_the_state_the_transition_left(self) -> None: + """The surfaces read the song as the hook fires, so the song is engaged by then.""" + logic = _make_logic() + logic._service.alive = False + engaged: List[bool] = [] + logic.on_view_changed = lambda _view: None + logic._service.start.side_effect = lambda **_position: setattr(logic._service, "alive", True) + logic.on_change_audio_state = lambda: engaged.append(logic.is_engaged()) + + logic.play() + + assert engaged == [True] + + class TestOnProjectReplaced: def test_on_project_replaced_stops_playback_and_resets_position(self) -> None: logic = _make_logic(is_open=True) From 60ff7c7b0626f29b6c560d74b53090956c06c185 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 18:42:29 +0200 Subject: [PATCH 12/84] Fixed: a run writing every reconstruction into the folder Destination names --- docs/development/bugs-and-todos.md | 4 - .../logic/main/converter/destination.py | 50 ++---- .../logic/main/converter/logic.py | 9 +- .../converter/paths/__init__.py | 2 + .../reconstructions/converter/paths/utils.py | 19 +- .../reconstructions/converter/plan/batch.py | 51 ++++-- .../converter/plan/directory.py | 10 +- .../reconstructions/converter/plan/group.py | 7 +- .../converter/plan/protocol.py | 7 + tests/screens/main/run/test_destination.py | 7 - .../logic/main/converter/test_destination.py | 40 ++++- .../converter/plan/test_plans.py | 167 ++++++++++++++++++ 12 files changed, 291 insertions(+), 82 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 78a836f30..0f2711602 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -159,10 +159,6 @@ currently out of line. An entry leaves when the code meets the contract again. 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/scan/test_reading_a_folder.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/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/library/test_folders_and_generators.py` reproduces it as a known failure. diff --git a/src/sampletones_application/logic/main/converter/destination.py b/src/sampletones_application/logic/main/converter/destination.py index 40a9755ce..497fa9dc3 100644 --- a/src/sampletones_application/logic/main/converter/destination.py +++ b/src/sampletones_application/logic/main/converter/destination.py @@ -1,25 +1,18 @@ from dataclasses import dataclass, replace from pathlib import Path -from typing import AbstractSet, Optional, Self, Tuple +from typing import Optional, Self from sampletones_application.logic.main.sources.list import SourceList from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName -from sampletones_core.reconstructions.converter import BatchEntry -from sampletones_core.reconstructions.converter.paths import ( - config_directory_path, - group_output_path, -) +from sampletones_core.reconstructions.converter import ConversionPlan @dataclass(frozen=True) class Destination: """What a run converts and where the reconstruction it writes lands. - The output path follows the input: a recording names the document beside it, a directory names - the tree its batch mirrors, and a mix names the document the gathered recordings amount to. - Deriving it in one step keeps the path the panel shows and the path the run writes the same - answer. + The output path is the one the run's plan names as its destination, so the path the panel + shows and the path the run writes are one answer. """ input_path: Optional[Path] @@ -39,37 +32,18 @@ def reconstruction_name(self) -> str: return self.input_path.stem if self.input_path is not None else "" - def aimed_at_mix( - self, - config: Config, - sources: Tuple[Path, ...], - channels: AbstractSet[ChannelName], - ) -> Self: - """The destination the recordings a mix gathers name between them. + def aimed_at(self, config: Config, plan: Optional[ConversionPlan]) -> Self: + """The destination the plan a run amounts to names. - A mix with nobody taking part names nothing of its own, so the destination it last held - stands until a recording joins it. + A run of one names the document it writes, which is what a reader converting a single file + is looking at; a larger one names the folder every reconstruction it writes lands in. A run + with nobody taking part names nothing of its own, so the destination it last held stands + until a recording joins it. """ - if not sources: + if plan is None: return self - return replace(self, output_path=group_output_path(config, sources, channels)) - - def aimed_at_batch(self, config: Config, entries: Tuple[BatchEntry, ...]) -> Self: - """The destination a run writing one reconstruction per recording names. - - One recording names the document it is written to, which is what a reader converting a - single file is looking at; several name the directory the channels they cover between them - are held under, which is the tree the batch writes into. - """ - if not entries: - return self - - if len(entries) == 1: - return replace(self, output_path=entries[0].output_path(config)) - - covered = frozenset().union(*(entry.stems.covered_channels for entry in entries)) - return replace(self, output_path=config_directory_path(config, covered)) + return replace(self, output_path=plan.destination(config)) def named_after(self, sources: SourceList) -> Self: """What a run names itself by, read from the sources gathered for it. diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index bfbd3fff5..f989491cb 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -20,9 +20,7 @@ ) from sampletones_application.logic.main.converter.settings import RunSettings from sampletones_application.logic.main.converter.setup import ( - batch_entries, conversion_plan, - conversion_setup, playing_sources, ) from sampletones_application.logic.main.converter.state import ConverterState @@ -550,13 +548,8 @@ def _remember(self, settings: RunSettings) -> None: def _redirected(self, state: ConverterState) -> ConverterState: """The setup with its destination following the sources that take part in it.""" - config = self._config_manager.config destination = state.destination.named_after(state.gathering.sources) - if state.settings.mixes: - setup = conversion_setup(state) - return state.with_destination(destination.aimed_at_mix(config, setup.sources, setup.stems.covered_channels)) - - return state.with_destination(destination.aimed_at_batch(config, batch_entries(state))) + return state.with_destination(destination.aimed_at(self._config_manager.config, conversion_plan(state))) def _standing_targets(self, plan: ConversionPlan) -> Tuple[Path, ...]: """Every reconstruction ``plan`` would write over, in the order the run reaches them. diff --git a/src/sampletones_core/reconstructions/converter/paths/__init__.py b/src/sampletones_core/reconstructions/converter/paths/__init__.py index 515e92e0d..f52f1c441 100644 --- a/src/sampletones_core/reconstructions/converter/paths/__init__.py +++ b/src/sampletones_core/reconstructions/converter/paths/__init__.py @@ -8,6 +8,7 @@ get_output_path, get_relative_path, group_output_path, + named_output_path, walk_audio_files, walk_entries, ) @@ -20,6 +21,7 @@ "get_output_path", "get_relative_path", "group_output_path", + "named_output_path", "walk_audio_files", "walk_entries", ] diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index e6b70cb00..a2769de5d 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -82,8 +82,23 @@ def group_output_path( Raises: ValueError: If ``sources`` is empty. """ - output_directory = config_directory_path(config, channels) - return Path((output_directory / f"{derive_name(sources)}{suffix}").absolute()) + return named_output_path(config_directory_path(config, channels), sources, suffix) + + +def named_output_path( + directory: Path, + sources: Tuple[Path, ...], + suffix: str = EXT_FILE_RECONSTRUCTION, +) -> Path: + """Where the one reconstruction built from ``sources`` is written inside ``directory``. + + The file takes the name the source rules derive: one source names it after itself, and several + after what they share. + + Raises: + ValueError: If ``sources`` is empty. + """ + return Path((directory / f"{derive_name(sources)}{suffix}").absolute()) def walk_entries(input_directory: Path) -> Iterator[Path]: diff --git a/src/sampletones_core/reconstructions/converter/plan/batch.py b/src/sampletones_core/reconstructions/converter/plan/batch.py index f5345b05b..464bae0e7 100644 --- a/src/sampletones_core/reconstructions/converter/plan/batch.py +++ b/src/sampletones_core/reconstructions/converter/plan/batch.py @@ -1,13 +1,14 @@ from dataclasses import dataclass from pathlib import Path -from typing import List, Optional, Tuple +from typing import FrozenSet, List, Optional, Tuple from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions.converter.job import ConversionJob from sampletones_core.reconstructions.converter.paths.utils import ( config_directory_path, get_relative_path, - group_output_path, + named_output_path, ) from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_shared.exceptions import NoFilesToProcessError @@ -19,7 +20,7 @@ class BatchEntry: ``base_directory`` names the folder the recording was gathered from, whose tree the written reconstructions mirror. A recording gathered by name carries none, and its reconstruction - sits directly in the directory the run's settings are named after. + sits directly in the directory its batch writes into. """ source: Path @@ -31,14 +32,12 @@ def is_named(self) -> bool: """The reader named this recording itself, rather than the folder holding it.""" return self.base_directory is None - def output_path(self, config: Config) -> Path: - """The reconstruction this recording is written to.""" - channels = self.stems.covered_channels + def output_path(self, directory: Path) -> Path: + """The reconstruction this recording is written to, inside the directory its batch writes into.""" if self.base_directory is None: - return group_output_path(config, (self.source,), channels) + return named_output_path(directory, (self.source,)) - mirrored = config_directory_path(config, channels) / self.base_directory.name - return get_relative_path(self.base_directory, self.source, mirrored) + return get_relative_path(self.base_directory, self.source, directory / self.base_directory.name) @dataclass(frozen=True) @@ -46,13 +45,35 @@ class BatchConversion: """One reconstruction per recording gathered, each built from that recording alone. Every recording carries the channels its own row holds, so one batch writes as many setups - as the reader worked out. A recording named by the reader is written whenever the batch - runs; one gathered from a folder is left as it stands where its reconstruction is already - written, so a repeated run over a folder picks up where the last one stopped. + as the reader worked out. They all land in one directory, named after every channel the + batch hands out, so the run fills one folder. A recording named by the reader is written + whenever the batch runs; one gathered from a folder is left as it stands where its + reconstruction is already written, so a repeated run over a folder picks up where the last + one stopped. """ entries: Tuple[BatchEntry, ...] + @property + def covered_channels(self) -> FrozenSet[ChannelName]: + """Every channel the batch hands out between its recordings.""" + return frozenset().union(*(entry.stems.covered_channels for entry in self.entries)) + + def directory(self, config: Config) -> Path: + """The directory every recording of this batch is written into. + + Raises: + pydantic.ValidationError: If the batch holds no recording, which hands out no channel. + """ + return config_directory_path(config, self.covered_channels) + + def destination(self, config: Config) -> Path: + """The reconstruction a batch of one writes, or the directory a larger batch writes into.""" + if len(self.entries) == 1: + return self.entries[0].output_path(self.directory(config)) + + return self.directory(config) + def jobs(self, config: Config) -> List[ConversionJob]: """The single-source jobs this batch writes. @@ -76,4 +97,8 @@ def existing_targets(self, config: Config) -> Tuple[Path, ...]: ) def _targets(self, config: Config) -> List[Tuple[BatchEntry, Path]]: - return [(entry, entry.output_path(config)) for entry in self.entries] + if not self.entries: + return [] + + directory = self.directory(config) + return [(entry, entry.output_path(directory)) for entry in self.entries] diff --git a/src/sampletones_core/reconstructions/converter/plan/directory.py b/src/sampletones_core/reconstructions/converter/plan/directory.py index 9238a7a33..8f18b69d1 100644 --- a/src/sampletones_core/reconstructions/converter/plan/directory.py +++ b/src/sampletones_core/reconstructions/converter/plan/directory.py @@ -32,13 +32,21 @@ def jobs(self, config: Config) -> List[ConversionJob]: Raises: NoFilesToProcessError: If the directory holds no audio file still to be converted. """ - output_path = get_output_path(config, self.directory, self.stems.covered_channels) + output_path = self.destination(config) audio_files = filter_files(get_audio_files(self.directory), self.directory, output_path) if not audio_files: raise NoFilesToProcessError(f"No audio files found in {self.directory}") return [self._job(audio_file, output_path) for audio_file in audio_files] + def destination(self, config: Config) -> Path: + """The folder mirroring the directory, which every reconstruction this conversion writes sits in. + + Raises: + FileNotFoundError: If the directory does not exist. + """ + return get_output_path(config, self.directory, self.stems.covered_channels) + def existing_targets(self, _config: Config) -> Tuple[Path, ...]: """The empty tuple: the scan converts what is still to be written and keeps the rest.""" return () diff --git a/src/sampletones_core/reconstructions/converter/plan/group.py b/src/sampletones_core/reconstructions/converter/plan/group.py index c9133acc2..ed0589d7a 100644 --- a/src/sampletones_core/reconstructions/converter/plan/group.py +++ b/src/sampletones_core/reconstructions/converter/plan/group.py @@ -24,14 +24,15 @@ def jobs(self, config: Config) -> List[ConversionJob]: ConversionJob( sources=self.sources, stems=self.stems, - output_path=self._output_path(config), + output_path=self.destination(config), ) ] def existing_targets(self, config: Config) -> Tuple[Path, ...]: """The one file this conversion writes, where it already stands.""" - output_path = self._output_path(config) + output_path = self.destination(config) return (output_path,) if output_path.is_file() else () - def _output_path(self, config: Config) -> Path: + def destination(self, config: Config) -> Path: + """The one reconstruction this conversion writes.""" return group_output_path(config, self.sources, self.stems.covered_channels) diff --git a/src/sampletones_core/reconstructions/converter/plan/protocol.py b/src/sampletones_core/reconstructions/converter/plan/protocol.py index 287f4daa3..3b4893fd1 100644 --- a/src/sampletones_core/reconstructions/converter/plan/protocol.py +++ b/src/sampletones_core/reconstructions/converter/plan/protocol.py @@ -16,6 +16,13 @@ class ConversionPlan(Protocol): def jobs(self, config: Config) -> List[ConversionJob]: ... + def destination(self, config: Config) -> Path: + """Where this plan writes: the one reconstruction a plan of one makes, or the folder the rest fill. + + A reader is shown this path before the run, so every reconstruction the plan's jobs write + is this file or lies inside this folder. + """ + def existing_targets(self, config: Config) -> Tuple[Path, ...]: """The reconstructions already standing where this plan would write. diff --git a/tests/screens/main/run/test_destination.py b/tests/screens/main/run/test_destination.py index 647837e3c..cb2cec685 100644 --- a/tests/screens/main/run/test_destination.py +++ b/tests/screens/main/run/test_destination.py @@ -1,8 +1,6 @@ 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 @@ -41,11 +39,6 @@ def test_each_recording_carries_the_channels_its_row_gave(self, screen: 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 diff --git a/tests/unit/sampletones_application/logic/main/converter/test_destination.py b/tests/unit/sampletones_application/logic/main/converter/test_destination.py index 98757f3d4..f5f148100 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_destination.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_destination.py @@ -1,10 +1,17 @@ from pathlib import Path -from typing import Tuple +from typing import Final from sampletones_application.logic.main.converter.destination import Destination from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.converter import BatchConversion, BatchEntry, GroupConversion +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from tests.suite.base import BaseTestSuite +_PULSE: Final[StemsConfig] = StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1])) +_TRIANGLE: Final[StemsConfig] = StemsConfig.single_entry(StemSettings.covering([ChannelName.TRIANGLE])) + class TestTheDocumentARunIsMaking(BaseTestSuite): """A run of one names itself after the reconstruction it writes.""" @@ -33,9 +40,30 @@ def test_a_completed_run_names_what_it_wrote(self) -> None: assert (destination.output_path, destination.reconstruction_name) == (written, "mixed") -class TestWhereAMixWrites(BaseTestSuite): - def test_a_mix_with_nobody_taking_part_stands_where_it_was(self) -> None: - destination = Destination.unset() - sources: Tuple[Path, ...] = () +class TestWhereARunWrites(BaseTestSuite): + """The destination names what the run's plan names, and a run with no plan names nothing new.""" + + def test_a_run_with_nobody_taking_part_stands_where_it_was(self) -> None: + destination = Destination.unset().writing_to(Path("/reconstructions/earlier.stn")) + + assert destination.aimed_at(Config(), None) == destination + + def test_a_mix_names_the_file_its_plan_writes(self, tmp_path: Path) -> None: + config = Config() + plan = GroupConversion(sources=(tmp_path / "a.wav", tmp_path / "b.wav"), stems=_PULSE) + + assert Destination.unset().aimed_at(config, plan).output_path == plan.destination(config) + + def test_a_batch_names_the_folder_its_plan_writes_into(self, tmp_path: Path) -> None: + config = Config() + plan = BatchConversion( + entries=( + BatchEntry(source=tmp_path / "lead.wav", stems=_PULSE, base_directory=None), + BatchEntry(source=tmp_path / "bass.wav", stems=_TRIANGLE, base_directory=None), + ) + ) + + output_path = Destination.unset().aimed_at(config, plan).output_path - assert destination.aimed_at_mix(Config(), sources, frozenset()) == destination + assert output_path == plan.destination(config) + assert all(job.output_path.parent == output_path for job in plan.jobs(config)) diff --git a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py index 0c54dcbf1..a01e8f157 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py +++ b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py @@ -340,6 +340,173 @@ def test_a_directory_conversion_settles_the_question_itself( assert plan.existing_targets(config) == () +class TestOneFolderPerBatch: + """A batch writes every recording into one folder, named after every channel it hands out.""" + + @pytest.fixture + def pulse(self) -> StemsConfig: + return StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1])) + + @pytest.fixture + def triangle(self) -> StemsConfig: + return StemsConfig.single_entry(StemSettings.covering([ChannelName.TRIANGLE])) + + def test_recordings_with_different_channels_share_the_folder_of_both( + self, + config: Config, + pulse: StemsConfig, + triangle: StemsConfig, + tmp_path: Path, + ) -> None: + lead, bass = _write_audio_files(tmp_path, ["lead.wav", "bass.wav"]) + plan = BatchConversion( + entries=( + BatchEntry(source=lead, stems=pulse, base_directory=None), + BatchEntry(source=bass, stems=triangle, base_directory=None), + ) + ) + + folder = config_directory_path(config, frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE})) + assert [job.output_path.parent for job in plan.jobs(config)] == [folder, folder] + assert plan.destination(config) == folder + + def test_the_folder_names_no_channel_one_recording_alone_takes( + self, + config: Config, + pulse: StemsConfig, + triangle: StemsConfig, + tmp_path: Path, + ) -> None: + """Neither recording's own folder is where the batch writes, since each names part of the run.""" + lead, bass = _write_audio_files(tmp_path, ["lead.wav", "bass.wav"]) + plan = BatchConversion( + entries=( + BatchEntry(source=lead, stems=pulse, base_directory=None), + BatchEntry(source=bass, stems=triangle, base_directory=None), + ) + ) + own_folders = { + config_directory_path(config, frozenset({ChannelName.PULSE1})), + config_directory_path(config, frozenset({ChannelName.TRIANGLE})), + } + + assert {job.output_path.parent for job in plan.jobs(config)}.isdisjoint(own_folders) + + def test_a_folder_gathered_with_different_channels_mirrors_into_the_one_folder( + self, + config: Config, + pulse: StemsConfig, + triangle: StemsConfig, + tmp_path: Path, + ) -> None: + root = tmp_path / "loops" + first, second = _write_audio_files(root, ["a.wav", "nested/b.wav"]) + plan = BatchConversion( + entries=( + BatchEntry(source=first, stems=pulse, base_directory=root), + BatchEntry(source=second, stems=triangle, base_directory=root), + ) + ) + + mirrored = config_directory_path(config, frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE})) / root.name + assert [job.output_path for job in plan.jobs(config)] == [mirrored / "a.stn", mirrored / "nested" / "b.stn"] + + def test_a_batch_of_one_names_the_file_it_writes( + self, + config: Config, + pulse: StemsConfig, + tmp_path: Path, + ) -> None: + source = _write_audio_files(tmp_path, ["lead.wav"])[0] + plan = BatchConversion(entries=(BatchEntry(source=source, stems=pulse, base_directory=None),)) + + assert plan.destination(config) == plan.jobs(config)[0].output_path + assert plan.destination(config) == group_output_path(config, (source,), frozenset({ChannelName.PULSE1})) + + def test_a_rerun_skips_what_stands_in_the_one_folder( + self, + pulse: StemsConfig, + triangle: StemsConfig, + tmp_path: Path, + ) -> None: + """A recording written in the folder of its own channels alone is written again into the batch's.""" + config = _config_writing_under(tmp_path / "out") + root = tmp_path / "loops" + first, second = _write_audio_files(root, ["a.wav", "b.wav"]) + plan = BatchConversion( + entries=( + BatchEntry(source=first, stems=pulse, base_directory=root), + BatchEntry(source=second, stems=triangle, base_directory=root), + ) + ) + in_the_batch_folder = plan.jobs(config)[0].output_path + in_its_own_folder = config_directory_path(config, frozenset({ChannelName.TRIANGLE})) / root.name / "b.stn" + for written in (in_the_batch_folder, in_its_own_folder): + written.parent.mkdir(parents=True, exist_ok=True) + written.touch() + + assert [job.sources[0] for job in plan.jobs(config)] == [second] + + +class TestEveryJobWritesWhereItsPlanSays: + """A plan's destination is the file its one job writes, or the folder every job writes inside.""" + + def test_a_group_names_the_file_its_job_writes( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + sources = tuple(_write_audio_files(tmp_path, ["a.wav", "b.wav"])) + plan = GroupConversion(sources=sources, stems=stems) + + assert [job.output_path for job in plan.jobs(config)] == [plan.destination(config)] + + def test_a_directory_names_the_folder_mirroring_it( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + root = tmp_path / "loops" + _write_audio_files(root, ["a.wav", "nested/b.wav"]) + plan = DirectoryConversion(directory=root, stems=stems) + + destination = plan.destination(config) + + assert destination == config_directory_path(config, CHANNELS) / root.name + assert all(destination in job.output_path.parents for job in plan.jobs(config)) + + def test_a_directory_that_is_gone_names_no_folder( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + with pytest.raises(FileNotFoundError): + DirectoryConversion(directory=tmp_path / "gone", stems=stems).destination(config) + + def test_a_batch_names_the_folder_every_job_writes_inside( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + root = tmp_path / "loops" + named = _write_audio_files(tmp_path, ["song.wav"])[0] + gathered = _write_audio_files(root, ["a.wav"])[0] + plan = BatchConversion( + entries=( + BatchEntry(source=named, stems=stems, base_directory=None), + BatchEntry(source=gathered, stems=stems, base_directory=root), + ) + ) + + destination = plan.destination(config) + + assert all(destination in job.output_path.parents for job in plan.jobs(config)) + + class TestGroupOutputPath: def test_one_source_names_the_file_after_itself(self, config: Config, tmp_path: Path) -> None: source = tmp_path / "song.wav" From d48109239a3f0fe65e0a93238929e592b7bde363 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 18:55:38 +0200 Subject: [PATCH 13/84] Fixed: two closes before the first is answered asking twice --- docs/development/application/dialogs.md | 13 + docs/development/bugs-and-todos.md | 4 - src/sampletones_application/application.py | 84 ++++-- .../coordinators/project.py | 94 ++++--- .../coordinators/reconstruction.py | 52 +++- .../coordinators/tabs/instructions.py | 8 +- .../coordinators/tabs/main.py | 8 +- .../coordinators/tabs/reconstruction.py | 4 +- .../tabs/sequencer/coordinator.py | 4 +- src/sampletones_application/shell.py | 19 +- .../utils/callbacks/gates.py | 96 ++++++- .../utils/gui/dialogs/renderer.py | 9 +- .../gui/dialogs/windows/save_confirmation.py | 29 +- .../prompts/closing/test_during_an_edit.py | 17 +- .../prompts/closing/test_over_a_question.py | 7 - .../coordinators/tabs/test_instructions.py | 9 +- .../coordinators/tabs/test_main.py | 9 +- .../coordinators/test_project.py | 123 ++++++++- .../coordinators/test_reconstruction.py | 26 +- .../test_application_exit.py | 88 +++++- .../test_application_gestures.py | 131 ++++++++- .../utils/callbacks/test_gates.py | 252 +++++++++++++++--- .../dialogs/windows/test_save_confirmation.py | 81 +++++- 23 files changed, 968 insertions(+), 199 deletions(-) diff --git a/docs/development/application/dialogs.md b/docs/development/application/dialogs.md index 65d6217f8..f9268fc44 100644 --- a/docs/development/application/dialogs.md +++ b/docs/development/application/dialogs.md @@ -82,6 +82,19 @@ A dialog that comes back once the modal it raised is answered steps aside. `yiel and keeps its tree, and `resume` brings it back, a frame each way. The dialog keeps the screen while it stands aside, so nothing waiting in line opens between it and the prompt it raised. +## A gesture asks once + +A gesture that asks before it replaces a document, closes one or leaves the application holds one +conversation at a time. The conversation is the chain of questions the gesture passes, and every way out of +a question reaches whoever asked it. An answer that goes on lets the request through. Cancel, Escape, the +title bar's close and a save that failed turn it away. A request therefore always ends in one of the two. + +While a conversation stands, the same gesture asked for again is absorbed, so two closes before the first is +answered ask once. Once the conversation has ended, the gesture asks again. The span covers the wait for the +edits of the open reconstruction too, so a gesture repeated while an edit is on its way asks once it lands. +`SingleFlight` (`utils/callbacks/gates.py`) holds the conversation, and the composition root wraps every such +gesture in one, whichever door it is asked for through. + ## Where it is written `GUIWindow.dialog_window` (`ui/elements/window.py`) is the only place a dialog's `dpg.window` is opened. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 8f5d69ee2..08922d013 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -189,10 +189,6 @@ currently out of line. An entry leaves when the code meets the contract again. * 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/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/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/player/test_source_switch.py` reproduces it as a known failure. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index c4f5cdd70..45c48912e 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1,6 +1,6 @@ from contextlib import ExitStack from pathlib import Path -from typing import Any, Dict, Final, Optional, Tuple +from typing import Any, Callable, Dict, Final, Optional, Tuple import dearpygui.dearpygui as dpg from pydantic import ValidationError @@ -133,7 +133,14 @@ from sampletones_application.ui.panels.dialogs.stem_selection import GUIStemSelectionWindow from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.setup import setup_themes -from sampletones_application.utils.callbacks.gates import gated, pass_gates +from sampletones_application.utils.callbacks.gates import ( + Gate, + GestureParameters, + GestureResult, + SingleFlight, + gated, + waiting, +) from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.file_dialogs.api import ( open_file_dialog, @@ -467,6 +474,7 @@ def __init__( on_session_state_changed=self._on_reconstruction_state_changed, on_reconstruction_updated=self._on_reconstruction_updated, ) + self._reconstruction_opening: SingleFlight[[Optional[Path]]] = self._reconstruction_opening_flight() self._original_audio_locator = OriginalAudioLocator( dialogs=self.dialogs, @@ -493,10 +501,7 @@ def __init__( export_service=self.export_service, export_backends=self.export_backends, format_setups=self._format_setups, - on_load_reconstruction_with_confirmation=gated( - self._reconstruction_coordinator.after_edits, - self._reconstruction_coordinator.load_with_confirmation, - ), + on_load_reconstruction_with_confirmation=self._reconstruction_opening, on_change_audio_state=self._update_menu, on_favorite_changed=self._repaint_reconstruction_favorites, on_rewrite_requested=self._reconstruction_coordinator.request_rewrite, @@ -542,10 +547,7 @@ def __init__( on_busy_state_changed=self._refresh_busy_state, on_reconstruct_file=self._reconstruct_file, on_reconstruct_directory=self._reconstruct_directory, - on_load_reconstruction=gated( - self._reconstruction_coordinator.after_edits, - self._reconstruction_coordinator.load_with_confirmation, - ), + on_load_reconstruction=self._reconstruction_opening, on_load_library=self._load_library, on_load_file=gated( self._reconstruction_coordinator.after_edits, @@ -664,6 +666,7 @@ def __init__( instructions_tab=self._instructions_tab, ) self.browser_manager.on_recordings_read = self._show_reconstruction_recordings + self._exiting: SingleFlight[[]] = self._exit_flight() self._setup_gui() self._restore_current_items( @@ -734,29 +737,42 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: A gesture that reads or puts away a whole document, the project or the open reconstruction, waits for the edits of the open reconstruction made before it, so it acts on what the - reader has drawn. + reader has drawn. A gesture that replaces or closes a document, or leaves, holds one + conversation at a time, so asking for it again before its question is answered asks once. """ after_edits = self._reconstruction_coordinator.after_edits return ShortcutBindings( - new_project=gated(after_edits, self._project_coordinator.new_project_with_confirmation), - open_project=gated(after_edits, self._project_coordinator.open_with_confirmation), + new_project=self._document_flight( + self._project_coordinator.guard_new, + self._project_coordinator.new_project, + ), + open_project=self._document_flight( + self._project_coordinator.guard_open, + self._project_coordinator.open_project, + ), save_project=gated(after_edits, self._project_coordinator.save), save_project_as=gated(after_edits, self._project_coordinator.save_as_dialog), project_properties=self._open_project_properties, export_project=gated(after_edits, self._project_coordinator.export_project_dialog), render_song=gated(after_edits, self._render_coordinator.open), - close_project=gated(after_edits, self._project_coordinator.close_with_confirmation), - exit=self._on_close, + close_project=self._document_flight( + self._project_coordinator.guard_close, + self._project_coordinator.close_project, + ), + exit=self._exiting, undo=self._sequencer_tab.undo, redo=self._sequencer_tab.redo, reconstruct_file=self._reconstruct_file_dialog, reconstruct_directory=self._reconstruct_directory_dialog, load_generation_settings=self._config_coordinator.load_dialog, save_generation_settings=self._config_coordinator.save_dialog, - open_reconstruction=gated(after_edits, self._reconstruction_coordinator.load_with_confirmation), + open_reconstruction=self._reconstruction_opening, save_reconstruction=gated(after_edits, self._reconstruction_coordinator.save), save_reconstruction_as=gated(after_edits, self._reconstruction_coordinator.save_as_dialog), - close_reconstruction=gated(after_edits, self._reconstruction_coordinator.close_with_confirmation), + close_reconstruction=self._document_flight( + self._reconstruction_coordinator.guard_close, + self._reconstruction_coordinator.close, + ), export_wav=gated(after_edits, self._export_reconstruction_wav_dialog), export_instruments=gated(after_edits, self._export_reconstruction_instruments_dialog), add_reconstruction_to_sequencer=gated(after_edits, self._add_current_reconstruction_to_sequencer), @@ -787,10 +803,31 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: select_tab=self._set_current_tab, ) + def _document_flight( + self, + guard: Gate, + arrive: Callable[GestureParameters, GestureResult], + ) -> SingleFlight[GestureParameters]: + """A gesture on a whole document as one conversation: the edits on their way land, then ``guard`` asks.""" + return SingleFlight( + ( + waiting(self._reconstruction_coordinator.after_edits), + guard, + ), + arrive, + ) + + def _reconstruction_opening_flight(self) -> SingleFlight[[Optional[Path]]]: + """Opening a reconstruction as one conversation, whichever door asks: the menu or a browser.""" + return self._document_flight( + self._reconstruction_coordinator.guard_load, + self._reconstruction_coordinator.open, + ) + def _setup_shell(self, bindings: ShortcutBindings) -> None: self._shell.setup( bindings, - on_close=self._on_close, + on_close=self._exiting, on_tab_changed=self._on_tab_changed, initial_menu_state=self._build_initial_menu_state(), ) @@ -1605,15 +1642,16 @@ def _mute_channel(self, generator: ChannelName) -> None: """Flips one channel of the sequencer's mix, the gesture the Channels submenu offers.""" self._sequencer_tab.toggle_channel(generator) - def _on_close(self) -> None: - """Exits once each owner of something unfinished has asked about it, one after another. + def _exit_flight(self) -> SingleFlight[[]]: + """The exit as one conversation, in which each owner of something unfinished asks in turn. The edits of the open reconstruction land first, so each question asks about what the - reader has drawn. + reader has drawn. A close asked for again while the questions stand is absorbed, and Cancel + on any of them ends the conversation. """ - pass_gates( + return SingleFlight( ( - self._reconstruction_coordinator.after_edits, + waiting(self._reconstruction_coordinator.after_edits), self._project_coordinator.guard_exit, self._reconstruction_coordinator.guard_exit, self._main_tab.guard_exit, diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 5d65f0dde..6dc67fefa 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -105,28 +105,41 @@ def project_name(self) -> Optional[str]: def is_unsaved(self) -> bool: return self._project_controller.is_dirty - def new_project_with_confirmation(self) -> None: + def guard_new(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets a new project take the open one's place, asking first what becomes of the open one. + + The signature is a :data:`Gate`, so the question leads the new project's conversation. + """ self._guard_open( title=GlobalDialogTitleElements.NEW_UNSAVED_PROJECT, message=GlobalMessageElements.NEW_UNSAVED_PROJECT, open_message=GlobalMessageElements.NEW_OPEN_PROJECT, - on_confirm=self._new, + proceed=proceed, + decline=decline, ) - def open_with_confirmation(self, filepath: Optional[Path] = None) -> None: - def open_project() -> None: - if filepath is None: - self._open_dialog() - else: - self._load(filepath) + def guard_open(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets another project take the open one's place, asking first what becomes of the open one. + The signature is a :data:`Gate`, so the question leads the opening's conversation. + """ self._guard_open( title=GlobalDialogTitleElements.OPEN_UNSAVED_PROJECT, message=GlobalMessageElements.OPEN_UNSAVED_PROJECT, open_message=GlobalMessageElements.OPEN_OPEN_PROJECT, - on_confirm=open_project, + proceed=proceed, + decline=decline, ) + def new_project(self) -> None: + """Puts a new project in place and brings the Sequencer forward.""" + self._project_controller.new() + self._on_tab_switch(Tab.SEQUENCER) + + def open_project(self) -> None: + """Opens the project file the reader picks.""" + self._open_dialog() + def load_project_safely(self, path: Path) -> None: """Loads the project a run starts on, the one the session remembers or the one it was given. @@ -142,27 +155,38 @@ def load_project_safely(self, path: Path) -> None: logger.warning(f"Could not restore project from {logger.format_path(path)}: {exception}") self._session_manager.set_current_project(None) - def close_with_confirmation(self) -> None: + def guard_close(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets the open project close, asking first to save unsaved changes. + + With no project open there is nothing to close, so the request is turned away. The + signature is a :data:`Gate`, so the question leads the closing's conversation. + """ if not self._project_controller.is_open: + decline() return - if self.is_unsaved: - self._dialogs.show_save_confirmation( - tag=TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, - title=self._title(GlobalDialogTitleElements.CLOSE_UNSAVED_PROJECT), - message=self._message(GlobalMessageElements.CLOSE_UNSAVED_PROJECT), - on_save=self._write_project, - on_confirm=self._close, - ok_label=self._label(DialogElements.DISCARD), - ) - else: - self._close() + if not self.is_unsaved: + proceed() + return + + self._dialogs.show_save_confirmation( + tag=TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, + title=self._title(GlobalDialogTitleElements.CLOSE_UNSAVED_PROJECT), + message=self._message(GlobalMessageElements.CLOSE_UNSAVED_PROJECT), + on_save=self._write_project, + on_confirm=proceed, + on_cancel=decline, + ok_label=self._label(DialogElements.DISCARD), + ) + + def close_project(self) -> None: + self._project_controller.close() - def guard_exit(self, proceed: VoidCallback) -> None: + def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: """Lets the exit go on, asking first to save a project with unsaved changes. Save and Exit both go on, so what the exit asks about next is asked in turn, and Cancel - keeps the application open. + keeps the application open and turns the exit away. """ if not self.is_unsaved: proceed() @@ -174,6 +198,7 @@ def guard_exit(self, proceed: VoidCallback) -> None: message=self._message(GlobalMessageElements.EXIT_UNSAVED_PROJECT), on_save=self._write_project, on_confirm=proceed, + on_cancel=decline, ok_label=self._label(DialogElements.EXIT), ) @@ -300,13 +325,6 @@ def _handle_export_project(self, filepath: Path, export_format: ExportFormat) -> self._project_controller.export_request, ) - def _new(self) -> None: - self._project_controller.new() - self._on_tab_switch(Tab.SEQUENCER) - - def _close(self) -> None: - self._project_controller.close() - def _load(self, filepath: Path) -> None: try: self._project_controller.load(filepath) @@ -387,10 +405,16 @@ def _guard_open( title: GlobalDialogTitleElements, message: GlobalMessageElements, open_message: GlobalMessageElements, - on_confirm: Callback, + proceed: VoidCallback, + decline: VoidCallback, ) -> None: + """Lets another project take the open one's place, asking first while one is open. + + Unsaved changes are offered a save, and an open project holding none is still asked about, + so a project is replaced only by an answer. Cancel turns the request away. + """ if not self._project_controller.is_open: - on_confirm() + proceed() return if self.is_unsaved: @@ -399,7 +423,8 @@ def _guard_open( title=self._title(title), message=self._message(message), on_save=self._write_project, - on_confirm=on_confirm, + on_confirm=proceed, + on_cancel=decline, ok_label=self._label(DialogElements.DISCARD), ) else: @@ -407,7 +432,8 @@ def _guard_open( tag=TAG_GLOBAL_DIALOG_PROJECT_OPEN, title=self._title(title), message=self._message(open_message), - on_confirm=on_confirm, + on_confirm=proceed, + on_cancel=decline, ok_label=self._label(DialogElements.DISCARD), ) diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 3dcffb235..697e1a7cc 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -1,3 +1,4 @@ +from functools import partial from pathlib import Path from typing import Callable, Optional, Tuple @@ -28,6 +29,7 @@ TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, ) +from sampletones_application.utils.callbacks.gates import ignore from sampletones_application.utils.file_dialogs.api import ( open_file_dialog, save_file_dialog, @@ -217,20 +219,30 @@ def _load_dialog(self) -> None: def load(self, filepath: Path) -> None: return self._tab.load_reconstruction(filepath) - def load_with_confirmation(self, filepath: Optional[Path] = None) -> None: - def load_reconstruction() -> None: - if filepath is None: - self._load_dialog() - else: - self.load(filepath) + def open(self, filepath: Optional[Path] = None) -> None: + """Loads ``filepath``, or the reconstruction file the reader picks where none is named.""" + if filepath is None: + self._load_dialog() + else: + self.load(filepath) + + def guard_load(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets another document take the open one's place, offering first to save unsaved changes. + The signature is a :data:`Gate`, so the question leads the loading's conversation. + """ self._save_first( title=self._language_manager["global.dialog.title.load_unsaved_reconstruction"], message=self._language_manager["global.dialog.message.load_unsaved_reconstruction"], ok_label=self._language_manager["global.dialog.label.discard"], - proceed=load_reconstruction, + proceed=proceed, + decline=decline, ) + def load_with_confirmation(self, filepath: Optional[Path] = None) -> None: + """Loads ``filepath``, or the file the reader picks, once unsaved changes are answered for.""" + self.guard_load(partial(self.open, filepath), ignore) + def load_converted(self, filepath: Path) -> None: """Loads the reconstruction a conversion wrote, asking first about unsaved changes. @@ -265,16 +277,18 @@ def load_reconstruction_safely(self, path: Path) -> None: logger.warning(f"Could not restore reconstruction from {logger.format_path(path)}: {exception}") self._session_manager.set_current_reconstruction(None) - def guard_exit(self, proceed: VoidCallback) -> None: + def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: """Lets the exit go on, asking first to save a standalone reconstruction with unsaved changes. A project sample's changes belong to the project, which the exit asks about on its own. + Cancel keeps the application open and turns the exit away. """ self._save_first( title=self._language_manager["global.dialog.title.exit_confirmation"], message=self._language_manager["global.dialog.message.exit_unsaved_reconstruction"], ok_label=self._language_manager["global.dialog.label.exit"], proceed=proceed, + decline=decline, ) @ignore_none_path @@ -312,15 +326,21 @@ def _report_save_failure(self, exception: Exception, filepath: Optional[Path]) - self._language_manager["global.dialog.message.reconstruction_save_failed"], ) - def close_with_confirmation(self) -> None: + def guard_close(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets the open document close, offering first to save unsaved changes. + + The signature is a :data:`Gate`, so the question leads the closing's conversation. + """ self._save_first( title=self._language_manager["global.dialog.title.close_unsaved_reconstruction"], message=self._language_manager["global.dialog.message.close_unsaved_reconstruction"], ok_label=self._language_manager["global.dialog.label.close"], - proceed=self._close, + proceed=proceed, + decline=decline, ) - def _close(self) -> None: + def close(self) -> None: + """Puts the open document away, with the edits still on their way to it.""" self._drop_pending() self._reconstruction_manager.close_reconstruction() @@ -333,7 +353,7 @@ def after_edits(self, gesture: VoidCallback) -> None: Undo, a save, a load or an export acts on the document the reader has drawn, so it waits for the edits still on their way. With nothing on its way, the gesture runs at once. The - signature is a :data:`Gate`, so the wait can lead a chain of gates. + signature is a :data:`Wait`, so the wait can lead a chain of gates. """ self._rewrites.request(AfterEdits(gesture)) @@ -407,6 +427,7 @@ def open_project_voice(self, voice_id: str) -> None: message=self._language_manager["global.dialog.message.edit_voice_unsaved_reconstruction"], ok_label=self._language_manager["global.dialog.label.discard"], proceed=lambda: self._open_project_voice(voice_id), + decline=ignore, ) def _open_project_voice(self, voice_id: str) -> None: @@ -482,14 +503,14 @@ def _follow(self, *, restored: bool) -> None: if restored: self._rebind(sample.reconstruction) case _: - self._close() + self.close() self._tab.follow_instrument(restored=restored) def _let_go_of_project_voice(self) -> None: """Closes a project voice the tab shows, leaving a standalone document open.""" if self._reconstruction_manager.is_project_sample: - self._close() + self.close() self._tab.close_instrument() @@ -538,6 +559,7 @@ def _save_first( message: str, ok_label: str, proceed: VoidCallback, + decline: VoidCallback, ) -> None: """Goes on with ``proceed``, offering first to save a standalone document with unsaved changes. @@ -546,6 +568,7 @@ def _save_first( message: What the prompt says would be lost. ok_label: The label of the answer that goes on without saving. proceed: What runs once the document is saved, or once the reader lets the changes go. + decline: What runs once the reader keeps the changes, or once the save fails. """ if not self._requires_save_confirmation(): proceed() @@ -557,5 +580,6 @@ def _save_first( message=message, on_save=self.save, on_confirm=proceed, + on_cancel=decline, ok_label=ok_label, ) diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index 27028f0f1..273eb270f 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -501,8 +501,11 @@ def save_browser_shape(self) -> None: def is_library_generating(self) -> bool: return self._library_logic.is_library_generating() - def guard_exit(self, proceed: VoidCallback) -> None: - """Lets the exit go on, asking first while a library is being built, which exiting stops.""" + def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets the exit go on, asking first while a library is being built, which exiting stops. + + Cancel keeps the library building and turns the exit away. + """ if not self.is_library_generating(): proceed() return @@ -513,6 +516,7 @@ def guard_exit(self, proceed: VoidCallback) -> None: self._language_manager["global.dialog.title.exit_confirmation"], proceed, ok_label=self._language_manager["global.dialog.label.exit"], + on_cancel=decline, ) def refresh_generate_button(self) -> None: diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 8aa150f59..cdba6564d 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -754,8 +754,11 @@ def _sync_config_row_height(self) -> None: def is_converter_active(self) -> bool: return self._converter_logic.is_active - def guard_exit(self, proceed: VoidCallback) -> None: - """Lets the exit go on, asking first while a conversion runs, which exiting stops.""" + def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets the exit go on, asking first while a conversion runs, which exiting stops. + + Cancel keeps the conversion running and turns the exit away. + """ if not self.is_converter_active(): proceed() return @@ -766,6 +769,7 @@ def guard_exit(self, proceed: VoidCallback) -> None: self._language_manager["global.dialog.title.exit_confirmation"], proceed, ok_label=self._language_manager["global.dialog.label.exit"], + on_cancel=decline, ) def is_converter_panel_visible(self) -> bool: diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 96744f9c2..47e197b1e 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -91,7 +91,7 @@ from sampletones_application.ui.panels.reconstruction.stems import ( GUIReconstructionStemsPanel, ) -from sampletones_application.utils.callbacks.gates import Gate, gated +from sampletones_application.utils.callbacks.gates import Wait, gated from sampletones_application.utils.file_dialogs.api import save_file_dialog from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path @@ -145,7 +145,7 @@ def __init__( on_favorite_changed: Callable[[FileSystemNode], None], on_rewrite_requested: Callable[[Rewrite], None], pending_changes: PendingChangesProtocol, - after_edits: Gate, + after_edits: Wait, original_audio_locator: OriginalAudioLocator, instrument_exports: InstrumentExportCoordinator, history: HistoryManager, diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index c85cc1031..f724de18c 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -62,7 +62,7 @@ from sampletones_application.ui.panels.sequencer.voices.panel import ( GUISequencerVoicesPanel, ) -from sampletones_application.utils.callbacks.gates import Gate, gated +from sampletones_application.utils.callbacks.gates import Wait, gated from sampletones_application.utils.gui.clipboard.selection import select_text_clipboard from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.frame import FrameCallbackManager @@ -115,7 +115,7 @@ def __init__( on_tab_switch: Callable[[Tab], None], on_nes_frequency_changed: Callable[[int], None], on_channels_changed: VoidCallback, - after_edits: Gate, + after_edits: Wait, ) -> None: self._project_controller = project_controller self._session_manager = session_manager diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index ffda1c087..3d7de08d9 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -55,7 +55,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.exports.format import ExportFormat from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import Callback, PathCallback +from sampletones_shared.types.callback import Callback, PathCallback, VoidCallback _TAB_TAGS: Dict[Tab, str] = { Tab.MAIN: TAG_GLOBAL_TAB_MAIN, @@ -168,7 +168,7 @@ def setup( self, bindings: ShortcutBindings, *, - on_close: Callback, + on_close: VoidCallback, on_tab_changed: Callback, initial_menu_state: MenuBarViewModel, ) -> None: @@ -183,7 +183,20 @@ def setup( self._setup_handlers() self._create_main_window(on_tab_changed, initial_menu_state) self._activate_background_work() - dpg.set_exit_callback(on_close) + dpg.set_exit_callback(ApplicationShell._window_close(on_close)) + + @staticmethod + def _window_close(on_close: VoidCallback) -> VoidCallback: + """``on_close`` as the window manager's close reaches it. + + The close is answered as a widget's callback is, handed as many of DearPyGui's sender, data + and user data as it declares. It declares none, so ``on_close`` is called with nothing. + """ + + def close() -> None: + on_close() + + return close def _activate_background_work(self) -> None: """Re-arm the background machinery for this run. diff --git a/src/sampletones_application/utils/callbacks/gates.py b/src/sampletones_application/utils/callbacks/gates.py index cbfd9da79..46c992378 100644 --- a/src/sampletones_application/utils/callbacks/gates.py +++ b/src/sampletones_application/utils/callbacks/gates.py @@ -1,51 +1,119 @@ from functools import partial -from typing import Callable, ParamSpec, Sequence, TypeVar +from typing import Callable, Generic, ParamSpec, Sequence, Tuple, TypeVar from sampletones_shared.types.callback import VoidCallback -Gate = Callable[[VoidCallback], None] +Wait = Callable[[VoidCallback], None] +Gate = Callable[[VoidCallback, VoidCallback], None] GestureParameters = ParamSpec("GestureParameters") GestureResult = TypeVar("GestureResult") -def pass_gates(gates: Sequence[Gate], arrive: VoidCallback) -> None: +def ignore() -> None: + """Answers a request turned away that nothing waits on, so the request ends where it stopped.""" + + +def waiting(wait: Wait) -> Gate: + """``wait`` as a gate that holds a request until it may go on, and then lets it through. + + Args: + wait: What a request waits on, such as the edits of the open reconstruction. + """ + + def gate(proceed: VoidCallback, _decline: VoidCallback) -> None: + wait(proceed) + + return gate + + +def pass_gates( + gates: Sequence[Gate], + arrive: VoidCallback, + decline: VoidCallback, +) -> None: """Runs each gate in turn, and calls ``arrive`` once the last one lets the request through. - Each gate receives a callback that runs the gates after it. A gate checks what it guards when - it is reached: it calls the callback at once, or asks a question whose answer calls it. An - earlier question is therefore answered before a later gate checks, and a state that cleared - meanwhile asks nothing. A declined question stops the request there. + Each gate receives a callback that runs the gates after it, and ``decline``. A gate checks what it + guards when it is reached: it calls the first at once, or asks a question whose answer calls one of + the two. An earlier question is therefore answered before a later gate checks, and a state that + cleared meanwhile asks nothing. A request ends in ``arrive`` or in ``decline``, so whoever waits on + it hears how it ended. Args: gates: What stands between a request and ``arrive``, in the order the gates are asked. arrive: What runs once every gate has let the request through. + decline: What runs once a gate turns the request away. """ if not gates: arrive() return - gates[0](partial(pass_gates, gates[1:], arrive)) + gates[0](partial(pass_gates, gates[1:], arrive, decline), decline) def gated( - gate: Gate, + wait: Wait, gesture: Callable[GestureParameters, GestureResult], ) -> Callable[GestureParameters, None]: - """``gesture`` as a callback that passes ``gate`` before it runs, with the arguments it was called with. + """``gesture`` as a callback that waits on ``wait`` before it runs, with the arguments it was called with. A menu item, a shortcut or a panel hook takes the callback in the gesture's place, so whatever - reaches the gesture goes through the gate first. The callback discards what the gesture returns. + reaches the gesture waits first. The callback discards what the gesture returns. Args: - gate: What the gesture waits on. - gesture: What runs once the gate lets it through. + wait: What the gesture waits on. + gesture: What runs once the wait lets it through. """ def call(*args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> None: def run() -> None: gesture(*args, **kwargs) - gate(run) + wait(run) return call + + +class SingleFlight(Generic[GestureParameters]): + """A gesture that holds one conversation at a time, absorbing a repeat asked for while one is in flight. + + The conversation is the gates the gesture passes. It is in flight from the moment the gesture is + asked for until the gates let it through or turn it away. A gesture asked for twice before its + question is answered therefore asks once, and one asked for after the answer asks again. A gate + that raises ends the flight too, so one failure leaves the gesture to be asked for again. + """ + + def __init__( + self, + gates: Sequence[Gate], + arrive: Callable[GestureParameters, GestureResult], + ) -> None: + self._gates: Tuple[Gate, ...] = tuple(gates) + self._arrive = arrive + self._in_flight: bool = False + + @property + def in_flight(self) -> bool: + """Whether a conversation of this gesture has been asked for and has not yet ended.""" + return self._in_flight + + def __call__(self, *args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> None: + if self._in_flight: + return + + def arrive() -> None: + self._land() + self._arrive(*args, **kwargs) + + self._in_flight = True + asked = False + try: + pass_gates(self._gates, arrive, self._land) + asked = True + finally: + if not asked: + self._land() + + def _land(self) -> None: + self._in_flight = False diff --git a/src/sampletones_application/utils/gui/dialogs/renderer.py b/src/sampletones_application/utils/gui/dialogs/renderer.py index 181a350bc..84e3081f1 100644 --- a/src/sampletones_application/utils/gui/dialogs/renderer.py +++ b/src/sampletones_application/utils/gui/dialogs/renderer.py @@ -317,6 +317,7 @@ def show_save_confirmation( title: str, on_save: Callable[[], SaveOutcome], on_confirm: Callback, + on_cancel: Callback, *, ok_label: str, ) -> None: @@ -325,9 +326,10 @@ def show_save_confirmation( Every answer runs once the prompt has left the screen, so whatever it opens stands alone. ``on_save`` writes the document and reports a :class:`SaveOutcome`: a written document runs ``on_confirm``, a save the reader called off brings the prompt back with the same - question, and a failed save leaves the error it showed alone on screen. The middle button - discards the pending changes and runs ``on_confirm`` to proceed, and Cancel — the initially - focused button — dismisses the prompt. + question, and a failed save leaves the error it showed alone on screen and runs + ``on_cancel``. The middle button discards the pending changes and runs ``on_confirm`` to + proceed. Cancel, the initially focused button, runs ``on_cancel``, and so do Escape and the + title bar's close button. """ GUISaveConfirmationWindow( tag=get_dialog_tag(tag), @@ -342,6 +344,7 @@ def show_save_confirmation( title, on_save, on_confirm, + on_cancel, ok_label=ok_label, ) diff --git a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py index 794c9023f..b54769a7d 100644 --- a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py +++ b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py @@ -27,9 +27,10 @@ class GUISaveConfirmationWindow(GUIDialogWindow): Every answer runs a frame after the prompt has left the screen, so whatever it opens stands alone. Save runs ``on_save``, which reports a :class:`SaveOutcome`: a written document runs ``on_confirm``, a save the reader called off brings the prompt back with the same question, - and a failed save leaves its error on screen by itself. The middle button discards the - pending changes and runs ``on_confirm`` to proceed, and Cancel — the initially focused - button — dismisses the prompt. + and a failed save leaves its error on screen by itself and runs ``on_cancel``. The middle + button discards the pending changes and runs ``on_confirm`` to proceed. Cancel, the initially + focused button, runs ``on_cancel``, and so do Escape and the title bar's close button, so every + way out of the prompt reaches the caller. """ def __init__( @@ -51,6 +52,7 @@ def __init__( self._title: str self._on_save: Callable[[], SaveOutcome] self._on_confirm: Callback + self._on_cancel: Callback self._ok_label: str super().__init__( @@ -66,14 +68,16 @@ def prepare( # pylint: disable=arguments-differ title: str, on_save: Callable[[], SaveOutcome], on_confirm: Callback, + on_cancel: Callback, *, ok_label: str, ) -> None: - """Captures the pending document's write and the two ways forward.""" + """Captures the pending document's write, the two ways forward and the way back.""" self._message = message self._title = title self._on_save = on_save self._on_confirm = on_confirm + self._on_cancel = on_cancel self._ok_label = ok_label def create_window(self) -> None: @@ -87,6 +91,9 @@ def _on_save() -> None: def _on_confirm() -> None: self._leave_then(self._on_confirm) + def _on_cancel() -> None: + self._leave_then(self._on_cancel) + def content(parent: str) -> None: dpg.add_text(self._message, parent=parent, wrap=self._wrap) @@ -107,22 +114,22 @@ def buttons(_: None) -> None: GUIButton( tag=cancel_button_tag, label=self._cancel_label, - callback=self.hide, + callback=_on_cancel, width=-1, ) buttons(None) - with self.dialog_window(label=self._title, on_close=self.hide): + with self.dialog_window(label=self._title, on_close=_on_cancel): content(self.tag) self._install_navigation( [ FocusStop.button(save_button_tag, _on_save), FocusStop.button(ok_button_tag, _on_confirm), - FocusStop.button(cancel_button_tag, self.hide), + FocusStop.button(cancel_button_tag, _on_cancel), ], - on_escape=self.hide, + on_escape=_on_cancel, initial_index=CANCEL_FOCUS_STOP, ) @@ -130,7 +137,8 @@ def _save_and_go_on(self) -> None: """Writes the document and goes where the outcome leads, once the prompt has left. A written document goes on to what the prompt was guarding. A save the reader called off - puts the same question again. A failed save showed its error, which stands alone. + puts the same question again. A failed save showed its error, which stands alone, and the + request the prompt guarded goes back the way Cancel takes it. """ match self._on_save(): case SaveOutcome.WRITTEN: @@ -141,7 +149,8 @@ def _save_and_go_on(self) -> None: self._title, self._on_save, self._on_confirm, + self._on_cancel, ok_label=self._ok_label, ) case SaveOutcome.FAILED: - pass + self._on_cancel() diff --git a/tests/screens/prompts/closing/test_during_an_edit.py b/tests/screens/prompts/closing/test_during_an_edit.py index f33bff2c4..2e6b26352 100644 --- a/tests/screens/prompts/closing/test_during_an_edit.py +++ b/tests/screens/prompts/closing/test_during_an_edit.py @@ -27,13 +27,9 @@ 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.""" + """Two closes during a held rebuild bring one question, Cancel leaves the screen quiet, and a + further close asks again.""" reconstructions = screen.reconstructions prompt = reconstructions.unsaved_prompt @@ -61,10 +57,19 @@ def cancel_leaves_no_question(screen: Screen) -> None: screen.frames(SETTLING_FRAMES) assert screen.shown_windows() == () + def close_once_more_and_leave(screen: Screen) -> None: + screen.close_window() + screen.expect(prompt.is_shown, bool, description="the question about leaving again") + + prompt.confirm() + + assert screen.wait_for_exit() + screen.scenario( edit_and_close_twice_while_the_rebuild_is_held, the_edit_lands_and_one_question_comes, cancel_leaves_no_question, + close_once_more_and_leave, ).run() def test_an_edit_made_after_the_close_is_drawn_away( diff --git a/tests/screens/prompts/closing/test_over_a_question.py b/tests/screens/prompts/closing/test_over_a_question.py index a9a770d85..c3bedc2e0 100644 --- a/tests/screens/prompts/closing/test_over_a_question.py +++ b/tests/screens/prompts/closing/test_over_a_question.py @@ -1,8 +1,6 @@ import operator from typing import Final -import pytest - from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName from sampletones_shared.paths.user import PROJECTS_DIRECTORY @@ -47,11 +45,6 @@ class TestClosingTheWindowTwiceAtOnce: application, and a further close asks once more and leaves on Exit. """ - @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: """Two closes bring one question, and Cancel keeps the application running.""" prompt = screen.project.unsaved_prompt diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py index fdfd3f686..a9ca6ba59 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py @@ -338,21 +338,26 @@ def _coordinator(self, *, generating: bool) -> InstructionsTabCoordinator: def test_an_idle_library_lets_the_exit_go_on(self) -> None: coordinator = self._coordinator(generating=False) proceed = MagicMock() + decline = MagicMock() - coordinator.guard_exit(proceed) + coordinator.guard_exit(proceed, decline) proceed.assert_called_once_with() + decline.assert_not_called() coordinator._dialogs.show_confirmation.assert_not_called() def test_a_library_being_built_asks_first(self) -> None: coordinator = self._coordinator(generating=True) proceed = MagicMock() + decline = MagicMock() - coordinator.guard_exit(proceed) + coordinator.guard_exit(proceed, decline) proceed.assert_not_called() + decline.assert_not_called() args, kwargs = coordinator._dialogs.show_confirmation.call_args assert args[0] == TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION assert args[1] == EXIT_LIBRARY_MESSAGE_KEY assert args[3] is proceed assert kwargs["ok_label"] == EXIT_LABEL_KEY + assert kwargs["on_cancel"] is decline diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index cfa3f0c5f..2e7b4552a 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -582,21 +582,26 @@ def _coordinator(self, *, active: bool) -> MainTabCoordinator: def test_an_idle_converter_lets_the_exit_go_on(self) -> None: coordinator = self._coordinator(active=False) proceed = MagicMock() + decline = MagicMock() - coordinator.guard_exit(proceed) + coordinator.guard_exit(proceed, decline) proceed.assert_called_once_with() + decline.assert_not_called() coordinator._dialogs.show_confirmation.assert_not_called() def test_a_running_conversion_asks_first(self) -> None: coordinator = self._coordinator(active=True) proceed = MagicMock() + decline = MagicMock() - coordinator.guard_exit(proceed) + coordinator.guard_exit(proceed, decline) proceed.assert_not_called() + decline.assert_not_called() args, kwargs = coordinator._dialogs.show_confirmation.call_args assert args[0] == TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION assert args[1] == EXIT_CONVERSION_MESSAGE_KEY assert args[3] is proceed assert kwargs["ok_label"] == EXIT_LABEL_KEY + assert kwargs["on_cancel"] is decline diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index a37df514e..9c45012ca 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -122,7 +122,7 @@ def test_a_new_project_asks_where_to_save( save_dialog: MagicMock, ) -> None: starting.load_project_safely(saved_project) - starting._new() + starting.new_project() assert starting.save() is SaveOutcome.CALLED_OFF @@ -549,32 +549,143 @@ def test_every_save_prompt_waits_on_the_quiet_save(self, saving: ProjectCoordina saving._project_controller.is_open = True saving._project_controller.is_dirty = True - saving.close_with_confirmation() - saving.new_project_with_confirmation() + saving.guard_close(MagicMock(), MagicMock()) + saving.guard_new(MagicMock(), MagicMock()) for prompt in saving._dialogs.show_save_confirmation.call_args_list: assert prompt.kwargs["on_save"] == saving._write_project class TestTheExitAsksAboutTheProject: - """Exiting with unsaved project changes asks to save them first, and the answer lets the exit go on.""" + """Exiting with unsaved project changes asks to save them first, the answer lets the exit go on, and + Cancel turns it away.""" def test_a_saved_project_lets_the_exit_go_on(self, project_coordinator: ProjectCoordinator) -> None: project_coordinator._project_controller.is_dirty = False proceed = MagicMock() + decline = MagicMock() - project_coordinator.guard_exit(proceed) + project_coordinator.guard_exit(proceed, decline) proceed.assert_called_once_with() + decline.assert_not_called() project_coordinator._dialogs.show_save_confirmation.assert_not_called() def test_an_unsaved_project_asks_to_save_first(self, project_coordinator: ProjectCoordinator) -> None: project_coordinator._project_controller.is_dirty = True proceed = MagicMock() + decline = MagicMock() - project_coordinator.guard_exit(proceed) + project_coordinator.guard_exit(proceed, decline) proceed.assert_not_called() + decline.assert_not_called() prompt = project_coordinator._dialogs.show_save_confirmation.call_args.kwargs assert prompt["on_save"] == project_coordinator._write_project assert prompt["on_confirm"] is proceed + assert prompt["on_cancel"] is decline + + +class TestReplacingOrClosingTheProject: + """New, Open and Close ask before the open project goes, and Cancel turns the request away. + + An unsaved project is offered a save, an open project holding no changes is still asked about, and a + request with no project open goes on at once, or, for Close, is turned away. + """ + + @pytest.fixture(name="proceed") + def proceed_fixture(self) -> MagicMock: + return MagicMock() + + @pytest.fixture(name="decline") + def decline_fixture(self) -> MagicMock: + return MagicMock() + + @pytest.mark.parametrize("guard", ["guard_new", "guard_open"]) + def test_no_project_open_goes_on_at_once( + self, + project_coordinator: ProjectCoordinator, + proceed: MagicMock, + decline: MagicMock, + guard: str, + ) -> None: + project_coordinator._project_controller.is_open = False + + {"guard_new": project_coordinator.guard_new, "guard_open": project_coordinator.guard_open}[guard]( + proceed, + decline, + ) + + proceed.assert_called_once_with() + decline.assert_not_called() + + @pytest.mark.parametrize("guard", ["guard_new", "guard_open", "guard_close"]) + def test_an_unsaved_project_asks_and_cancel_turns_the_request_away( + self, + project_coordinator: ProjectCoordinator, + proceed: MagicMock, + decline: MagicMock, + guard: str, + ) -> None: + project_coordinator._project_controller.is_open = True + project_coordinator._project_controller.is_dirty = True + + { + "guard_new": project_coordinator.guard_new, + "guard_open": project_coordinator.guard_open, + "guard_close": project_coordinator.guard_close, + }[guard](proceed, decline) + + proceed.assert_not_called() + prompt = project_coordinator._dialogs.show_save_confirmation.call_args.kwargs + assert prompt["on_confirm"] is proceed + assert prompt["on_cancel"] is decline + + @pytest.mark.parametrize("guard", ["guard_new", "guard_open"]) + def test_a_saved_project_is_asked_about_and_cancel_turns_the_request_away( + self, + project_coordinator: ProjectCoordinator, + proceed: MagicMock, + decline: MagicMock, + guard: str, + ) -> None: + project_coordinator._project_controller.is_open = True + project_coordinator._project_controller.is_dirty = False + + {"guard_new": project_coordinator.guard_new, "guard_open": project_coordinator.guard_open}[guard]( + proceed, + decline, + ) + + proceed.assert_not_called() + prompt = project_coordinator._dialogs.show_confirmation.call_args.kwargs + assert prompt["on_confirm"] is proceed + assert prompt["on_cancel"] is decline + + def test_a_saved_project_closes_at_once( + self, + project_coordinator: ProjectCoordinator, + proceed: MagicMock, + decline: MagicMock, + ) -> None: + project_coordinator._project_controller.is_open = True + project_coordinator._project_controller.is_dirty = False + + project_coordinator.guard_close(proceed, decline) + + proceed.assert_called_once_with() + decline.assert_not_called() + + def test_closing_with_no_project_open_is_turned_away( + self, + project_coordinator: ProjectCoordinator, + proceed: MagicMock, + decline: MagicMock, + ) -> None: + project_coordinator._project_controller.is_open = False + + project_coordinator.guard_close(proceed, decline) + + proceed.assert_not_called() + decline.assert_called_once_with() + project_coordinator._dialogs.show_save_confirmation.assert_not_called() diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index fe5051600..dac362379 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -454,7 +454,7 @@ def test_close_prompts_only_for_standalone_unsaved( embedded=test_case.embedded, ) - coordinator.close_with_confirmation() + coordinator.guard_close(coordinator.close, MagicMock()) if test_case.expects_prompt: coordinator._dialogs.show_save_confirmation.assert_called_once() @@ -1358,21 +1358,37 @@ class TestCase(BaseRegularTestCase): def test_the_exit_asks_only_about_a_standalone_document(self, test_case: TestCase) -> None: coordinator = _gating_coordinator(unsaved=test_case.unsaved, embedded=test_case.embedded) proceed = MagicMock() + decline = MagicMock() - coordinator.guard_exit(proceed) + coordinator.guard_exit(proceed, decline) assert coordinator._dialogs.show_save_confirmation.called is test_case.expected assert proceed.called is not test_case.expected + decline.assert_not_called() - def test_the_answer_lets_the_exit_go_on(self) -> None: + def test_the_answer_lets_the_exit_go_on_and_cancel_turns_it_away(self) -> None: coordinator = _gating_coordinator(unsaved=True, embedded=False) proceed = MagicMock() + decline = MagicMock() - coordinator.guard_exit(proceed) + coordinator.guard_exit(proceed, decline) prompt = coordinator._dialogs.show_save_confirmation.call_args.kwargs assert prompt["on_save"] == coordinator.save assert prompt["on_confirm"] is proceed + assert prompt["on_cancel"] is decline + + @pytest.mark.parametrize("guard", ["guard_load", "guard_close"]) + def test_cancel_on_a_load_or_a_close_turns_the_request_away(self, guard: str) -> None: + coordinator = _gating_coordinator(unsaved=True, embedded=False) + proceed = MagicMock() + decline = MagicMock() + + {"guard_load": coordinator.guard_load, "guard_close": coordinator.guard_close}[guard](proceed, decline) + + prompt = coordinator._dialogs.show_save_confirmation.call_args.kwargs + assert prompt["on_confirm"] is proceed + assert prompt["on_cancel"] is decline @pytest.fixture @@ -1498,7 +1514,7 @@ def test_closing_mid_flight_leaves_nothing_unsaved( turns_path: Path, ) -> None: _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) - following_coordinator.close_with_confirmation() + following_coordinator.guard_close(following_coordinator.close, MagicMock()) held_queue.drain() assert reconstruction_manager.current_reconstruction is None diff --git a/tests/unit/sampletones_application/test_application_exit.py b/tests/unit/sampletones_application/test_application_exit.py index eb32884e0..6ff03e459 100644 --- a/tests/unit/sampletones_application/test_application_exit.py +++ b/tests/unit/sampletones_application/test_application_exit.py @@ -22,30 +22,33 @@ def __init__(self, name: str, asked: List[str]) -> None: self.editing = False self._asked = asked self._proceed: Optional[VoidCallback] = None - self._after_edits: Optional[VoidCallback] = None + self._decline: Optional[VoidCallback] = None + self._after_edits: List[VoidCallback] = [] def after_edits(self, gesture: VoidCallback) -> None: - """Holds the gesture while an edit is on its way, the way the reconstruction's rewrites do.""" + """Holds the gesture while an edit is on its way, in line, the way the reconstruction's rewrites do.""" if not self.editing: gesture() return - self._after_edits = gesture + self._after_edits.append(gesture) def land(self) -> None: - """The edit on its way landing, which lets a gesture waiting on it run.""" - assert self._after_edits is not None - gesture, self._after_edits = self._after_edits, None + """The edit on its way landing, which lets every gesture waiting on it run in the order it came.""" + assert self._after_edits + gestures, self._after_edits = self._after_edits, [] self.editing = False - gesture() + for gesture in gestures: + gesture() - def guard_exit(self, proceed: VoidCallback) -> None: + def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: if not self.unfinished: proceed() return self._asked.append(self.name) self._proceed = proceed + self._decline = decline @property def is_asking(self) -> bool: @@ -54,12 +57,16 @@ def is_asking(self) -> bool: def go_on(self) -> None: """The reader answering the question with Save, Exit or Discard.""" assert self._proceed is not None - proceed, self._proceed = self._proceed, None + proceed = self._proceed + self._proceed, self._decline = None, None proceed() def cancel(self) -> None: - """The reader answering the question with Cancel, which ends the exit.""" - self._proceed = None + """The reader answering the question with Cancel, which turns the exit away.""" + assert self._decline is not None + decline = self._decline + self._proceed, self._decline = None, None + decline() class Exiting: @@ -75,13 +82,14 @@ def __init__(self) -> None: self.application._instructions_tab = self.owners[LIBRARY] self.exit = MagicMock() self.application._exit_application = self.exit + self.application._exiting = self.application._exit_flight() def unfinished(self, *names: str) -> None: for name in names: self.owners[name].unfinished = True def close(self) -> None: - self.application._on_close() + self.application._exiting() @pytest.fixture(name="exiting") @@ -197,3 +205,59 @@ def test_the_application_exits_once_it_lands_with_nothing_unfinished(self, exiti exiting.owners[RECONSTRUCTION].land() exiting.exit.assert_called_once_with() + + +class TestClosingTwiceBeforeTheAnswer: + """A close asked for again while the exit's questions stand is absorbed, and Cancel ends the exit. + + A close made after the answer asks again, so the reader can always leave. + """ + + def test_two_closes_over_a_question_ask_once(self, exiting: Exiting) -> None: + exiting.unfinished(PROJECT) + + exiting.close() + exiting.close() + + assert exiting.asked == [PROJECT] + + def test_cancel_leaves_no_question_behind(self, exiting: Exiting) -> None: + exiting.unfinished(PROJECT) + exiting.close() + exiting.close() + + exiting.owners[PROJECT].cancel() + + assert not exiting.owners[PROJECT].is_asking + assert exiting.asked == [PROJECT] + exiting.exit.assert_not_called() + + def test_a_close_after_cancel_asks_again(self, exiting: Exiting) -> None: + exiting.unfinished(PROJECT) + exiting.close() + exiting.owners[PROJECT].cancel() + + exiting.close() + exiting.owners[PROJECT].go_on() + + assert exiting.asked == [PROJECT, PROJECT] + exiting.exit.assert_called_once_with() + + def test_a_close_while_a_later_question_stands_is_absorbed(self, exiting: Exiting) -> None: + exiting.unfinished(PROJECT, CONVERSION) + exiting.close() + exiting.owners[PROJECT].go_on() + + exiting.close() + + assert exiting.asked == [PROJECT, CONVERSION] + + def test_two_closes_while_an_edit_is_on_its_way_ask_once_it_lands(self, exiting: Exiting) -> None: + exiting.owners[RECONSTRUCTION].editing = True + exiting.unfinished(RECONSTRUCTION) + exiting.close() + exiting.close() + + exiting.owners[RECONSTRUCTION].land() + + assert exiting.asked == [RECONSTRUCTION] diff --git a/tests/unit/sampletones_application/test_application_gestures.py b/tests/unit/sampletones_application/test_application_gestures.py index 0077e1f27..608dc1242 100644 --- a/tests/unit/sampletones_application/test_application_gestures.py +++ b/tests/unit/sampletones_application/test_application_gestures.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from pathlib import Path from typing import Callable, Final, Tuple from unittest.mock import MagicMock @@ -8,6 +9,7 @@ from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator from sampletones_application.shell import ShortcutBindings from sampletones_core.exports.format import ExportFormat +from sampletones_shared.types.callback import VoidCallback from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.gates import HeldGate, held_gate @@ -27,19 +29,35 @@ "_add_current_reconstruction_to_sequencer", "_export_reconstruction_wav_dialog", "_export_reconstruction_instruments_dialog", + "_exiting", ) +PROJECT_GUARDS: Final[Tuple[str, ...]] = ("guard_new", "guard_open", "guard_close") +RECONSTRUCTION_GUARDS: Final[Tuple[str, ...]] = ("guard_load", "guard_close") + + +def let_through(proceed: VoidCallback, _decline: VoidCallback) -> None: + """A guard with nothing to ask about, which lets the request through at once.""" + proceed() @pytest.fixture def app(held_gate: HeldGate) -> Application: - """An application whose collaborators are stand-ins, and whose open reconstruction has an edit on its way.""" + """An application whose collaborators are stand-ins, and whose open reconstruction has an edit on its way. + + Each document's guard has nothing to ask about, so a gesture reaches its action once the edits land. + """ app = Application.__new__(Application) for name in COLLABORATORS: setattr(app, name, MagicMock()) for name in OWN_GESTURES: setattr(app, name, MagicMock()) + for name in PROJECT_GUARDS: + getattr(app._project_coordinator, name).side_effect = let_through app._reconstruction_coordinator = MagicMock(spec=ReconstructionCoordinator) app._reconstruction_coordinator.after_edits.side_effect = held_gate + for name in RECONSTRUCTION_GUARDS: + getattr(app._reconstruction_coordinator, name).side_effect = let_through + app._reconstruction_opening = app._reconstruction_opening_flight() return app @@ -70,22 +88,22 @@ class TestCase(BaseRegularTestCase): TestCase( label="new_project", press=lambda bindings: bindings.new_project(), - gesture=lambda app: app._project_coordinator.new_project_with_confirmation, + gesture=lambda app: app._project_coordinator.new_project, ), TestCase( label="open_project", press=lambda bindings: bindings.open_project(), - gesture=lambda app: app._project_coordinator.open_with_confirmation, + gesture=lambda app: app._project_coordinator.open_project, ), TestCase( label="close_project", press=lambda bindings: bindings.close_project(), - gesture=lambda app: app._project_coordinator.close_with_confirmation, + gesture=lambda app: app._project_coordinator.close_project, ), TestCase( label="open_reconstruction", press=lambda bindings: bindings.open_reconstruction(), - gesture=lambda app: app._reconstruction_coordinator.load_with_confirmation, + gesture=lambda app: app._reconstruction_coordinator.open, ), TestCase( label="save_reconstruction", @@ -100,7 +118,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="close_reconstruction", press=lambda bindings: bindings.close_reconstruction(), - gesture=lambda app: app._reconstruction_coordinator.close_with_confirmation, + gesture=lambda app: app._reconstruction_coordinator.close, ), TestCase( label="export_wav", @@ -170,3 +188,104 @@ def test_undo_leaves_the_wait_to_the_sequencer( """The sequencer's undo waits on the same edits, so the key reaches it as it stands.""" assert bindings.undo == app._sequencer_tab.undo assert bindings.redo == app._sequencer_tab.redo + + +class TestADocumentGestureAsksOnce(BaseTestSuite): + """A gesture that replaces or closes a document, asked for twice while the edits before it are on + their way, asks its question once. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + press: Callable[[ShortcutBindings], None] + guard: Callable[[Application], MagicMock] + + test_cases = ( + TestCase( + label="new_project", + press=lambda bindings: bindings.new_project(), + guard=lambda app: app._project_coordinator.guard_new, + ), + TestCase( + label="open_project", + press=lambda bindings: bindings.open_project(), + guard=lambda app: app._project_coordinator.guard_open, + ), + TestCase( + label="close_project", + press=lambda bindings: bindings.close_project(), + guard=lambda app: app._project_coordinator.guard_close, + ), + TestCase( + label="open_reconstruction", + press=lambda bindings: bindings.open_reconstruction(), + guard=lambda app: app._reconstruction_coordinator.guard_load, + ), + TestCase( + label="close_reconstruction", + press=lambda bindings: bindings.close_reconstruction(), + guard=lambda app: app._reconstruction_coordinator.guard_close, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_two_presses_ask_once( + self, + test_case: TestCase, + app: Application, + bindings: ShortcutBindings, + held_gate: HeldGate, + ) -> None: + test_case.guard(app).side_effect = None + + test_case.press(bindings) + test_case.press(bindings) + held_gate.release() + + test_case.guard(app).assert_called_once() + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_press_after_the_answer_asks_again( + self, + test_case: TestCase, + app: Application, + bindings: ShortcutBindings, + held_gate: HeldGate, + ) -> None: + test_case.guard(app).side_effect = None + test_case.press(bindings) + held_gate.release() + _, decline = test_case.guard(app).call_args.args + + decline() + test_case.press(bindings) + held_gate.release() + + assert test_case.guard(app).call_count == 2 + + def test_a_browser_and_the_menu_share_one_opening( + self, + app: Application, + bindings: ShortcutBindings, + held_gate: HeldGate, + ) -> None: + """Opening a reconstruction asks once whichever door it was asked for through.""" + guard = app._reconstruction_coordinator.guard_load + guard.side_effect = None + + app._reconstruction_opening(Path("browsed.stn")) + bindings.open_reconstruction() + held_gate.release() + + guard.assert_called_once() + proceed, _ = guard.call_args.args + proceed() + app._reconstruction_coordinator.open.assert_called_once_with(Path("browsed.stn")) diff --git a/tests/unit/sampletones_application/utils/callbacks/test_gates.py b/tests/unit/sampletones_application/utils/callbacks/test_gates.py index 8ebd3bc08..bd6ca2c82 100644 --- a/tests/unit/sampletones_application/utils/callbacks/test_gates.py +++ b/tests/unit/sampletones_application/utils/callbacks/test_gates.py @@ -1,8 +1,14 @@ -from typing import List, Optional +from pathlib import Path +from typing import Final, List, Optional -from sampletones_application.utils.callbacks.gates import pass_gates +import pytest + +from sampletones_application.utils.callbacks.gates import SingleFlight, gated, pass_gates, waiting from sampletones_shared.types.callback import VoidCallback +ARRIVED: Final[str] = "arrived" +DECLINED: Final[str] = "declined" + class Guard: """A gate over one unfinished thing, recording when it is reached and holding its question.""" @@ -11,77 +17,255 @@ def __init__(self, name: str, reached: List[str], *, unfinished: bool) -> None: self.name = name self.unfinished = unfinished self._reached = reached - self.question: Optional[VoidCallback] = None + self._proceed: Optional[VoidCallback] = None + self._decline: Optional[VoidCallback] = None - def __call__(self, proceed: VoidCallback) -> None: + def __call__(self, proceed: VoidCallback, decline: VoidCallback) -> None: self._reached.append(self.name) if not self.unfinished: proceed() return - self.question = proceed + self._proceed = proceed + self._decline = decline + + @property + def is_asking(self) -> bool: + return self._proceed is not None def answer(self) -> None: """The reader going on past the question this gate asked.""" - assert self.question is not None - question, self.question = self.question, None - question() + assert self._proceed is not None + proceed = self._proceed + self._proceed, self._decline = None, None + proceed() + def cancel(self) -> None: + """The reader turning the request away at the question this gate asked.""" + assert self._decline is not None + decline = self._decline + self._proceed, self._decline = None, None + decline() -class TestPassingGates: - def test_no_gate_arrives_at_once(self) -> None: - arrived: List[str] = [] - pass_gates((), lambda: arrived.append("arrived")) +class HeldWait: + """A wait a case lets go of by hand, the way the open reconstruction's edits hold a gesture until they land.""" + + def __init__(self) -> None: + self._held: List[VoidCallback] = [] + + def __call__(self, proceed: VoidCallback) -> None: + self._held.append(proceed) + + def release(self) -> None: + held, self._held = self._held, [] + for proceed in held: + proceed() + - assert arrived == ["arrived"] +@pytest.fixture(name="reached") +def reached_fixture() -> List[str]: + return [] + + +class TestPassingGates: + def test_no_gate_arrives_at_once(self, reached: List[str]) -> None: + pass_gates((), lambda: reached.append(ARRIVED), lambda: reached.append(DECLINED)) - def test_open_gates_are_passed_in_order(self) -> None: - reached: List[str] = [] + assert reached == [ARRIVED] + + def test_open_gates_are_passed_in_order(self, reached: List[str]) -> None: gates = [Guard(name, reached, unfinished=False) for name in ("first", "second", "third")] - pass_gates(gates, lambda: reached.append("arrived")) + pass_gates(gates, lambda: reached.append(ARRIVED), lambda: reached.append(DECLINED)) - assert reached == ["first", "second", "third", "arrived"] + assert reached == ["first", "second", "third", ARRIVED] - def test_a_question_holds_the_gates_after_it(self) -> None: - reached: List[str] = [] + def test_a_question_holds_the_gates_after_it(self, reached: List[str]) -> None: asking = Guard("first", reached, unfinished=True) - pass_gates((asking, Guard("second", reached, unfinished=False)), lambda: reached.append("arrived")) + pass_gates( + (asking, Guard("second", reached, unfinished=False)), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) assert reached == ["first"] - def test_an_answer_goes_on_to_the_next_gate(self) -> None: - reached: List[str] = [] + def test_an_answer_goes_on_to_the_next_gate(self, reached: List[str]) -> None: asking = Guard("first", reached, unfinished=True) - pass_gates((asking, Guard("second", reached, unfinished=False)), lambda: reached.append("arrived")) + pass_gates( + (asking, Guard("second", reached, unfinished=False)), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) asking.answer() - assert reached == ["first", "second", "arrived"] + assert reached == ["first", "second", ARRIVED] - def test_a_declined_question_arrives_nowhere(self) -> None: - """Declining answers nothing, so the gates after it and the arrival wait for good.""" - reached: List[str] = [] + def test_a_declined_question_runs_the_decline_alone(self, reached: List[str]) -> None: + """The gates after a declined question and the arrival stay unreached, and the decline says so.""" first = Guard("first", reached, unfinished=True) second = Guard("second", reached, unfinished=True) third = Guard("third", reached, unfinished=False) - pass_gates((first, second, third), lambda: reached.append("arrived")) + pass_gates((first, second, third), lambda: reached.append(ARRIVED), lambda: reached.append(DECLINED)) first.answer() + second.cancel() - assert reached == ["first", "second"] + assert reached == ["first", "second", DECLINED] - def test_a_gate_reads_what_it_guards_when_it_is_reached(self) -> None: + def test_a_gate_reads_what_it_guards_when_it_is_reached(self, reached: List[str]) -> None: """A state an earlier question saw settled meanwhile asks nothing once its gate is reached.""" - reached: List[str] = [] asking = Guard("first", reached, unfinished=True) later = Guard("second", reached, unfinished=True) - pass_gates((asking, later), lambda: reached.append("arrived")) + pass_gates((asking, later), lambda: reached.append(ARRIVED), lambda: reached.append(DECLINED)) later.unfinished = False asking.answer() - assert reached == ["first", "second", "arrived"] - assert later.question is None + assert reached == ["first", "second", ARRIVED] + assert not later.is_asking + + +class TestAWaitAsAGate: + def test_a_waiting_gate_holds_the_request_until_the_wait_ends(self, reached: List[str]) -> None: + wait = HeldWait() + + pass_gates((waiting(wait),), lambda: reached.append(ARRIVED), lambda: reached.append(DECLINED)) + assert reached == [] + + wait.release() + + assert reached == [ARRIVED] + + def test_a_gated_gesture_runs_with_its_arguments_once_the_wait_ends(self) -> None: + wait = HeldWait() + opened: List[Path] = [] + gesture = gated(wait, opened.append) + + gesture(Path("song.stp")) + assert opened == [] + + wait.release() + + assert opened == [Path("song.stp")] + + +class TestSingleFlight: + """A gesture holds one conversation at a time: a repeat while it is in flight is absorbed. + + The flight ends when the gates let the gesture through or turn it away, so a gesture asked for + after either asks again. + """ + + @pytest.fixture(name="guard") + def guard_fixture(self, reached: List[str]) -> Guard: + return Guard("question", reached, unfinished=True) + + @pytest.fixture(name="flight") + def flight_fixture(self, guard: Guard, reached: List[str]) -> SingleFlight[[]]: + return SingleFlight((guard,), lambda: reached.append(ARRIVED)) + + def test_a_repeat_while_the_question_stands_is_absorbed( + self, + flight: SingleFlight[[]], + reached: List[str], + ) -> None: + flight() + flight() + + assert reached == ["question"] + assert flight.in_flight + + def test_the_absorbed_repeat_arrives_once( + self, + flight: SingleFlight[[]], + guard: Guard, + reached: List[str], + ) -> None: + flight() + flight() + + guard.answer() + + assert reached == ["question", ARRIVED] + assert not flight.in_flight + + def test_a_gesture_after_an_arrival_asks_again( + self, + flight: SingleFlight[[]], + guard: Guard, + reached: List[str], + ) -> None: + flight() + guard.answer() + + flight() + + assert reached == ["question", ARRIVED, "question"] + assert flight.in_flight + + def test_a_gesture_after_a_decline_asks_again( + self, + flight: SingleFlight[[]], + guard: Guard, + reached: List[str], + ) -> None: + flight() + guard.cancel() + assert not flight.in_flight + + flight() + + assert reached == ["question", "question"] + assert guard.is_asking + + def test_a_repeat_while_a_wait_holds_the_gesture_is_absorbed(self, reached: List[str]) -> None: + wait = HeldWait() + guard = Guard("question", reached, unfinished=True) + flight: SingleFlight[[]] = SingleFlight((waiting(wait), guard), lambda: reached.append(ARRIVED)) + + flight() + flight() + wait.release() + + assert reached == ["question"] + + def test_the_arrival_takes_the_arguments_of_the_gesture_that_asked(self, reached: List[str]) -> None: + guard = Guard("question", reached, unfinished=True) + opened: List[Path] = [] + flight: SingleFlight[[Path]] = SingleFlight((guard,), opened.append) + + flight(Path("first.stn")) + flight(Path("second.stn")) + guard.answer() + + assert opened == [Path("first.stn")] + + def test_a_gesture_with_nothing_to_ask_arrives_and_lands(self, reached: List[str]) -> None: + flight: SingleFlight[[]] = SingleFlight( + (Guard("clear", reached, unfinished=False),), + lambda: reached.append(ARRIVED), + ) + + flight() + flight() + + assert reached == ["clear", ARRIVED, "clear", ARRIVED] + + def test_a_gate_that_raises_ends_the_flight(self, reached: List[str]) -> None: + def broken(_proceed: VoidCallback, _decline: VoidCallback) -> None: + reached.append("broken") + raise RuntimeError("the question could not be asked") + + flight: SingleFlight[[]] = SingleFlight((broken,), lambda: reached.append(ARRIVED)) + + with pytest.raises(RuntimeError): + flight() + + assert not flight.in_flight + with pytest.raises(RuntimeError): + flight() + assert reached == ["broken", "broken"] diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py index 5e3185802..b6efecf2f 100644 --- a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py @@ -18,15 +18,17 @@ from sampletones_application.utils.gui.dialogs.windows.save_confirmation import ( GUISaveConfirmationWindow, ) -from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.keyboard import KeyEvent, KeyRouter +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_shared.types.callback import VoidCallback from tests.suite.frames import Frames -from tests.suite.shortcuts import shipped_source +from tests.suite.shortcuts import shipped_scheme, shipped_source WINDOW_TAG: Final[str] = get_dialog_tag(TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) MESSAGE: Final[str] = "Save first?" SAVED: Final[str] = "saved" CONFIRMED: Final[str] = "confirmed" +CANCELED: Final[str] = "canceled" @pytest.fixture(name="router") @@ -68,6 +70,7 @@ def save() -> SaveOutcome: "Title", save, lambda: answers.append(CONFIRMED), + lambda: answers.append(CANCELED), ok_label="Proceed", ) window.create_window() @@ -83,6 +86,13 @@ def press(suffix: str) -> None: button_callback(suffix)() +def escape(router: KeyRouter) -> None: + """The press the shipped scheme cancels a dialog with, routed the way the keyboard sends it.""" + combination = shipped_scheme().shortcut(ShortcutId.DIALOG_CANCEL).combination + assert combination is not None + router.route(KeyEvent(key=combination.key, modifiers=combination.modifiers)) + + @pytest.fixture(name="placed") def placed_fixture(layout_config: LayoutConfig) -> Iterator[None]: """Stands in for the viewport a prompt raised again is centered against, which a suite draws none of.""" @@ -170,18 +180,19 @@ def test_the_prompt_back_holds_the_keyboard_once( assert len(router._modal_stack) == 1 - def test_a_failed_save_leaves_the_error_alone_on_screen( + def test_a_failed_save_leaves_the_error_alone_on_screen_and_answers_cancel( self, window: GUISaveConfirmationWindow, held_frames: Frames, ) -> None: + """The request the prompt guarded goes no further, and its caller hears that it ended.""" answers: List[str] = [] render(window, save_outcome=SaveOutcome.FAILED, answers=answers) press(SUF_BUTTON_SAVE) held_frames.render() - assert answers == [SAVED] + assert answers == [SAVED, CANCELED] assert not dpg.does_item_exist(WINDOW_TAG) assert held_frames.pending == 0 @@ -202,7 +213,20 @@ def test_the_middle_button_goes_on_once_the_prompt_has_left( held_frames.render() assert answers == [CONFIRMED] - def test_cancel_leaves_and_answers_nothing( + def test_cancel_leaves_before_it_answers( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + + press(SUF_BUTTON_CANCEL) + + assert not dpg.does_item_exist(WINDOW_TAG) + assert answers == [] + + def test_cancel_answers_cancel_a_frame_later( self, window: GUISaveConfirmationWindow, router: KeyRouter, @@ -214,10 +238,55 @@ def test_cancel_leaves_and_answers_nothing( press(SUF_BUTTON_CANCEL) held_frames.render() - assert answers == [] + assert answers == [CANCELED] assert not dpg.does_item_exist(WINDOW_TAG) assert not router.is_modal_open + def test_escape_answers_as_cancel( + self, + window: GUISaveConfirmationWindow, + router: KeyRouter, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + + escape(router) + held_frames.render() + + assert answers == [CANCELED] + assert not dpg.does_item_exist(WINDOW_TAG) + + def test_the_title_bar_close_answers_as_cancel( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.WRITTEN, answers=answers) + + dpg.get_item_configuration(WINDOW_TAG)["on_close"]() + held_frames.render() + + assert answers == [CANCELED] + assert not dpg.does_item_exist(WINDOW_TAG) + + def test_a_save_called_off_and_then_canceled_answers_cancel( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + placed: None, + ) -> None: + answers: List[str] = [] + render(window, save_outcome=SaveOutcome.CALLED_OFF, answers=answers) + press(SUF_BUTTON_SAVE) + held_frames.render() + + press(SUF_BUTTON_CANCEL) + held_frames.render() + + assert answers == [SAVED, CANCELED] + def test_a_second_press_answers_nothing( self, window: GUISaveConfirmationWindow, From 014a8f1d09a5d2e1f7b2051d6963a0e742971841 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 18:57:53 +0200 Subject: [PATCH 14/84] Fixed: a library folder pointed away from and back keeping its library loaded --- docs/development/bugs-and-todos.md | 3 - .../logic/instruction/library_manager.py | 81 ++++++++---- .../library/test_folders_and_generators.py | 5 - .../logic/instruction/test_library_logic.py | 16 ++- .../logic/instruction/test_library_manager.py | 115 +++++++++++++++--- 5 files changed, 168 insertions(+), 52 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 0f2711602..41f74f27d 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -159,9 +159,6 @@ currently out of line. An entry leaves when the code meets the contract again. 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/scan/test_reading_a_folder.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/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/vanished/test_reconstruction_removed.py` reproduces it as a known failure. diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index d8c3a7e7d..03a812caf 100644 --- a/src/sampletones_application/logic/instruction/library_manager.py +++ b/src/sampletones_application/logic/instruction/library_manager.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from functools import partial from pathlib import Path from typing import Callable, Dict, Optional, Tuple @@ -39,6 +40,14 @@ OnGenerationErrorCallback = Callable[[Exception], None] +@dataclass +class _Catalog: + """The libraries one directory holds in memory, and the one taken up as current there.""" + + library: InstructionLibrary + current_key: Optional[InstructionLibraryKey] + + class InstructionsLibraryManager(CallbackMixin): def __init__( self, @@ -48,10 +57,9 @@ def __init__( ) -> None: self._language_manager = language_manager self._config_manager = config_manager - library_directory = config_manager.get_library_directory() - self._library = InstructionLibrary(directory=str(library_directory)) + self._catalogs: Dict[Path, _Catalog] = {} + self._catalog = self._catalog_at(config_manager.get_library_directory()) self._listed_libraries: Dict[InstructionLibraryKey, bool] = {} - self._current_library_key: Optional[InstructionLibraryKey] = None self._tree = Tree() self._creator: Optional[InstructionsLibraryCreator] = None @@ -65,12 +73,28 @@ def __init__( @property def library_directory(self) -> Path: - return to_path(self._library.directory) + return to_path(self._catalog.library.directory) def set_library_directory(self, directory: Path) -> None: - """Roots the catalog at ``directory``, which keeps the libraries loaded from where it already stands.""" - if directory != self.library_directory: - self._library = InstructionLibrary(directory=str(directory)) + """Roots the catalog at ``directory``, keeping what every directory has loaded. + + A directory read before brings back the libraries it loaded and the one it had taken up as + current, so a reader pointing the catalog away and back finds it as they left it. + """ + self._catalog = self._catalog_at(directory) + + def _catalog_at(self, directory: Path) -> _Catalog: + """The catalog of ``directory``, started empty the first time the directory is read.""" + root = to_path(directory) + catalog = self._catalogs.get(root) + if catalog is None: + catalog = _Catalog( + library=InstructionLibrary(directory=str(root)), + current_key=None, + ) + self._catalogs[root] = catalog + + return catalog def gather_available_libraries(self) -> None: """Lists the library files standing in the catalog's directory, marking each one another @@ -82,8 +106,9 @@ def gather_available_libraries(self) -> None: if filepath.is_file() and filepath.suffix == EXT_FILE_LIBRARY and self._is_library_file(filepath.stem): listed[create_key_from_filename(filepath)] = library_state(filepath) is LibraryState.OUTDATED - for removed_key in set(self._listed_libraries) - set(listed): - self._library.data.pop(removed_key, None) + loaded = self._catalog.library.data + for removed_key in set(loaded) - set(listed): + del loaded[removed_key] self._listed_libraries = listed @@ -93,11 +118,11 @@ def is_library_loaded(self, library_key: InstructionLibraryKey) -> bool: A load takes only a library this build reads, so a library held here is one to use as it stands. """ - return library_key in self._library.data + return library_key in self._catalog.library.data def library_state(self, library_key: InstructionLibraryKey) -> LibraryState: """Where the library ``library_key`` names stands in the catalog for this build.""" - return self._library.state(library_key) + return self._catalog.library.state(library_key) def stored_config(self, library_key: InstructionLibraryKey) -> Optional[InstructionsLibraryConfig]: """The settings the library ``library_key`` names states it was built for, where its file @@ -111,23 +136,24 @@ def load_library(self, library_key: InstructionLibraryKey) -> InstructionLibrary """Takes up the library ``library_key`` names as the current one, reading it from its file where the catalog holds it in no memory yet.""" if not self.is_library_loaded(library_key): - self._library.load_data(library_key) + self._catalog.library.load_data(library_key) - self._current_library_key = library_key - return self._library.data[library_key] + self._catalog.current_key = library_key + return self._catalog.library.data[library_key] def load_instruction( self, instruction: InstructionUnion, ) -> Optional[InstructionPanelData]: - if not self._current_library_key or not self.is_library_loaded(self._current_library_key): + current_key = self._catalog.current_key + if not current_key or not self.is_library_loaded(current_key): return None - data = self._library.data[self._current_library_key] + data = self._catalog.library.data[current_key] fragment = data[instruction] library_config = data.config instruction_data = InstructionPanelData( - library_key=self._current_library_key, + library_key=current_key, instruction=instruction, config=library_config, fragment=fragment, @@ -136,14 +162,14 @@ def load_instruction( return instruction_data def get_path(self, library_key: InstructionLibraryKey) -> Path: - return self._library.get_path(library_key) + return self._catalog.library.get_path(library_key) def sync_with_config_key( self, config_key: InstructionLibraryKey, ) -> Optional[InstructionLibraryKey]: if self.library_state(config_key) is LibraryState.CURRENT: - self._current_library_key = config_key + self._catalog.current_key = config_key return config_key return None @@ -186,7 +212,7 @@ def _on_progress(status: TaskStatus, progress: TaskProgress) -> None: self._creator.set_callbacks( on_start=self.on_generation_start, - on_completed=partial(self._complete_generation, self._library), + on_completed=partial(self._complete_generation, self._catalog), on_error=self.on_generation_error, on_canceled=self.on_generation_canceled, on_progress=_on_progress, @@ -196,22 +222,22 @@ def _on_progress(status: TaskStatus, progress: TaskProgress) -> None: def _complete_generation( self, - library: InstructionLibrary, + catalog: _Catalog, result: Tuple[InstructionLibraryKey, InstructionLibraryData], ) -> None: - """Writes the generated library into ``library``, the catalog the generation was started in. + """Writes the generated library into ``catalog``, the one the generation was started in. The library becomes the current one only where the catalog still stands there. """ key, library_data = result try: - library.save_data(key, library_data) + catalog.library.save_data(key, library_data) except OSError as exception: self.call(self.on_generation_error, exception) raise - if library is self._library: - self._current_library_key = key + if catalog is self._catalog: + catalog.current_key = key self.call(self.on_generation_completed) @@ -230,10 +256,11 @@ def tree(self) -> Tree: @property def current_library_key(self) -> Optional[InstructionLibraryKey]: - return self._current_library_key + """The library taken up as current in the catalog standing now.""" + return self._catalog.current_key def clear_current_library(self) -> None: - self._current_library_key = None + self._catalog.current_key = None @property def creator(self) -> Optional[InstructionsLibraryCreator]: diff --git a/tests/screens/main/library/test_folders_and_generators.py b/tests/screens/main/library/test_folders_and_generators.py index 95920d895..af7f7b9a8 100644 --- a/tests/screens/main/library/test_folders_and_generators.py +++ b/tests/screens/main/library/test_folders_and_generators.py @@ -103,11 +103,6 @@ def point_back(screen: Screen) -> 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: """After pointing away and back, the first library's status reads loaded without a second opening.""" library = screen.instructions.library diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py index 514b6f152..b4f5c8b44 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py @@ -547,13 +547,27 @@ def test_another_directory_is_read_afresh(self, catalog: Catalog, tmp_path: Path assert (catalog.manager.library_directory, catalog.rebuilds_under_lock) == (other, [False]) def test_the_same_directory_repaints_over_what_is_loaded(self, catalog: Catalog) -> None: - catalog.manager._library.save_data(catalog.config_manager.key, WrittenLibrary()) + catalog.manager._catalog.library.save_data(catalog.config_manager.key, WrittenLibrary()) aim_library_directory(catalog.config_manager, catalog.config_manager.get_library_directory()) assert catalog.rebuilds_under_lock == [] assert catalog.views[-1].generate_button_label == "Regenerate" + def test_a_directory_pointed_away_from_and_back_repaints_its_library_loaded( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + ours = catalog.config_manager.get_library_directory() + catalog.manager._catalog.library.save_data(catalog.config_manager.key, WrittenLibrary()) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + away = catalog.views[-1].generate_button_label + + aim_library_directory(catalog.config_manager, ours) + + assert (away, catalog.views[-1].generate_button_label) == ("Generate", "Regenerate") + @ENDINGS def test_a_change_during_a_generation_stands_whatever_the_generation_came_to( self, diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_manager.py b/tests/unit/sampletones_application/logic/instruction/test_library_manager.py index 0f6be44ea..935c022bd 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_manager.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_manager.py @@ -114,31 +114,98 @@ def test_a_library_another_version_built_is_marked_and_holds_no_generators( class TestTheDirectoryTheCatalogStandsAt: - """The catalog holds the libraries it loaded for as long as it reads the same directory.""" + """Each directory keeps the libraries it loaded and the one it took up, however often the catalog + moves away and back.""" + + @staticmethod + def _loaded_here(library_manager: InstructionsLibraryManager, key: InstructionLibraryKey) -> None: + """Writes the library ``key`` names into the directory the catalog stands at, and loads it.""" + _create_library_file(library_manager, key) + library_manager.load_library(key) def test_the_directory_it_stands_at_keeps_what_it_loaded( self, config_manager: ConfigManager, library_manager: InstructionsLibraryManager, ) -> None: - library_manager._library.save_data(config_manager.key, WrittenLibrary()) + self._loaded_here(library_manager, config_manager.key) library_manager.set_library_directory(config_manager.get_library_directory()) assert library_manager.is_library_loaded(config_manager.key) is True - def test_another_directory_starts_with_nothing_loaded( + def test_another_directory_starts_with_nothing_loaded_or_taken_up( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + self._loaded_here(library_manager, config_manager.key) + other = tmp_path / OTHER_LIBRARIES + + library_manager.set_library_directory(other) + + assert ( + library_manager.library_directory, + library_manager.is_library_loaded(config_manager.key), + library_manager.current_library_key, + ) == (other, False, None) + + def test_pointing_away_and_back_finds_the_library_loaded_and_taken_up( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + ours = config_manager.get_library_directory() + self._loaded_here(library_manager, config_manager.key) + + library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) + library_manager.gather_available_libraries() + library_manager.set_library_directory(ours) + library_manager.gather_available_libraries() + + assert (library_manager.is_library_loaded(config_manager.key), library_manager.current_library_key) == ( + True, + config_manager.key, + ) + + def test_what_the_other_directory_took_up_stays_with_it( self, config_manager: ConfigManager, library_manager: InstructionsLibraryManager, tmp_path: Path, ) -> None: - library_manager._library.save_data(config_manager.key, WrittenLibrary()) + ours = config_manager.get_library_directory() other = tmp_path / OTHER_LIBRARIES + library_manager.set_library_directory(other) + self._loaded_here(library_manager, config_manager.key) + + library_manager.set_library_directory(ours) + assert (library_manager.is_library_loaded(config_manager.key), library_manager.current_library_key) == ( + False, + None, + ) library_manager.set_library_directory(other) + assert library_manager.current_library_key == config_manager.key - assert (library_manager.library_directory, library_manager._library.data) == (other, {}) + def test_a_library_whose_file_left_while_away_is_let_go( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + ours = config_manager.get_library_directory() + self._loaded_here(library_manager, config_manager.key) + library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) + library_manager.gather_available_libraries() + (ours / config_manager.key.filename).unlink() + + library_manager.set_library_directory(ours) + library_manager.gather_available_libraries() + + assert library_manager.is_library_loaded(config_manager.key) is False class TestCompleteGeneration: @@ -153,15 +220,15 @@ def test_file_error_reports_and_reraises( self, library_manager: InstructionsLibraryManager, ) -> None: - library = MagicMock() - library.save_data.side_effect = PermissionError("save failed") + catalog = MagicMock() + catalog.library.save_data.side_effect = PermissionError("save failed") error_callback = MagicMock() completed_callback = MagicMock() library_manager.on_generation_error = error_callback library_manager.on_generation_completed = completed_callback with pytest.raises(PermissionError): - library_manager._complete_generation(library, (MagicMock(), MagicMock())) + library_manager._complete_generation(catalog, (MagicMock(), MagicMock())) error_callback.assert_called_once() completed_callback.assert_not_called() @@ -170,13 +237,13 @@ def test_unexpected_error_propagates_directly( self, library_manager: InstructionsLibraryManager, ) -> None: - library = MagicMock() - library.save_data.side_effect = RuntimeError("unexpected") + catalog = MagicMock() + catalog.library.save_data.side_effect = RuntimeError("unexpected") error_callback = MagicMock() library_manager.on_generation_error = error_callback with pytest.raises(RuntimeError): - library_manager._complete_generation(library, (MagicMock(), MagicMock())) + library_manager._complete_generation(catalog, (MagicMock(), MagicMock())) error_callback.assert_not_called() @@ -184,14 +251,14 @@ def test_successful_save_sets_current_key_and_completes( self, library_manager: InstructionsLibraryManager, ) -> None: - library_manager._library = MagicMock() + library_manager._catalog.library = MagicMock() completed_callback = MagicMock() library_manager.on_generation_completed = completed_callback key = MagicMock() - library_manager._complete_generation(library_manager._library, (key, MagicMock())) + library_manager._complete_generation(library_manager._catalog, (key, MagicMock())) - assert library_manager._current_library_key is key + assert library_manager.current_library_key is key completed_callback.assert_called_once() def test_a_library_lands_where_its_generation_started( @@ -200,17 +267,33 @@ def test_a_library_lands_where_its_generation_started( library_manager: InstructionsLibraryManager, tmp_path: Path, ) -> None: - started_in = library_manager._library + started_in = library_manager._catalog library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) library_manager._complete_generation(started_in, (config_manager.key, WrittenLibrary())) - assert started_in.get_path(config_manager.key).exists() + assert started_in.library.get_path(config_manager.key).exists() assert (library_manager.library_state(config_manager.key), library_manager.current_library_key) == ( LibraryState.MISSING, None, ) + def test_a_library_generated_while_away_stands_loaded_on_the_way_back( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + """The generation's catalog is the one its directory brings back, so the library is held there.""" + ours = config_manager.get_library_directory() + started_in = library_manager._catalog + library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) + library_manager._complete_generation(started_in, (config_manager.key, WrittenLibrary())) + + library_manager.set_library_directory(ours) + + assert library_manager.is_library_loaded(config_manager.key) is True + class TestTheLibraryAConversionWaitsFor: """A conversion waits out a generation, then takes the library or gives the request up.""" From 85db526067db627a2cb7fb3012f67e11c5c843b4 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 18:58:54 +0200 Subject: [PATCH 15/84] Fixed: new voices reaching a pool with no project open --- docs/development/bugs-and-todos.md | 3 - .../tabs/sequencer/coordinator.py | 1 + .../coordinators/tabs/sequencer/voices.py | 11 +++- .../ui/panels/sequencer/voices/menu.py | 10 ++- .../ui/panels/sequencer/voices/panel.py | 22 ++++++- .../sequencer/voices/test_voices_card.py | 59 ++++++++++++------ tests/suite/screens/views/sequencer.py | 4 ++ .../coordinators/tabs/test_sequencer.py | 61 ++++++++++++++++++- .../ui/panels/sequencer/voices/test_menu.py | 50 ++++++++++++++- 9 files changed, 195 insertions(+), 26 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 6d4fcfcf8..d9392e3cc 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -187,9 +187,6 @@ currently out of line. An entry leaves when the code meets the contract again. * 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/player/test_source_switch.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/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/history/test_voice_gestures.py` reproduces it as a known failure. diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 44bc4af49..8271d0564 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -743,6 +743,7 @@ def refresh(self) -> None: self._sequencer_tracker_panel.set_enabled(is_open) self._sequencer_order_panel.set_enabled(is_open) self._sequencer_history_panel.set_enabled(is_open) + self._sequencer_voices_panel.set_enabled(is_open) def repaint(self) -> None: """Draws every table again so its tints take the palette now in place. diff --git a/src/sampletones_application/coordinators/tabs/sequencer/voices.py b/src/sampletones_application/coordinators/tabs/sequencer/voices.py index d6ec751a4..debbe6e06 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/voices.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/voices.py @@ -30,6 +30,9 @@ class SequencerVoices: Every one of them is a whole gesture, so each records the single history entry that takes the pool back to where it stood. A gesture reaching a file reads it before the pool is touched, which leaves a file the reader cannot use with the project and the history as they were. + + A voice enters the pool of an open project alone. Each way in asks for one as it starts, so the + rule holds wherever the gesture came from: a button, a menu, or a key bound to it. """ def __init__( @@ -64,6 +67,9 @@ def add_instrument(self) -> None: envelopes stay the reader's to write; naming it by its position gives the list a readable entry until they rename it. """ + if not self._open_project.met(): + return + name = self._language_manager["sequencer.voices.template.instrument_name"].format( position=display_id(self._project_controller.voice_count), ) @@ -106,8 +112,11 @@ def add_sample_from_file(self) -> None: The tree beside the list reaches the reconstructions folder, so a file kept elsewhere arrives through the system's own browser, which opens on the folder the last one came - from. + from. A project is asked for first, since a voice needs a pool to land in. """ + if not self._open_project.met(): + return + filepath = open_file_dialog( title=self._language_manager["sequencer.voices.title.add_sample_dialog"], initial_directory=self._session_manager.get_reconstruction_path(), diff --git a/src/sampletones_application/ui/panels/sequencer/voices/menu.py b/src/sampletones_application/ui/panels/sequencer/voices/menu.py index 02019cf6f..a804693fd 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/menu.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/menu.py @@ -65,6 +65,9 @@ class VoicesMenuHost(Protocol): @property def voice_count(self) -> int: ... + @property + def accepts_voices(self) -> bool: ... + def start_rename(self, voice_id: str) -> None: ... @@ -135,21 +138,26 @@ def add_pool_items(self) -> None: A voice is written by hand, converted from a recording, or brought from a tracker, and the three stand apart from the actions a listed voice offers, since each answers with an entry the list did not hold. Every door onto the list prints this section, so a reader - reaches it from the list and from a row alike. + reaches it from the list and from a row alike. The three answer while a project stands + open to take a voice, as the button above the list does. """ + accepts_voices = self._panel.accepts_voices dpg.add_menu_item( label=self._label(SequencerVoicesElements.NEW_INSTRUMENT), shortcut=self._shortcuts.display(ShortcutId.NEW_INSTRUMENT), + enabled=accepts_voices, callback=lambda: self.call(self._panel.on_new_instrument_requested), ) dpg.add_menu_item( label=self._label(SequencerVoicesElements.ADD_SAMPLE), shortcut=self._shortcuts.display(ShortcutId.ADD_SAMPLE_FROM_FILE), + enabled=accepts_voices, callback=lambda: self.call(self._panel.on_add_sample_requested), ) dpg.add_menu_item( label=self._label(SequencerVoicesElements.IMPORT_INSTRUMENT), shortcut=self._shortcuts.display(ShortcutId.IMPORT_INSTRUMENT), + enabled=accepts_voices, callback=lambda: self.call(self._panel.on_import_instrument_requested), ) diff --git a/src/sampletones_application/ui/panels/sequencer/voices/panel.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py index 15de17f9d..0c7933f74 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -29,7 +29,11 @@ from sampletones_application.ui.panels.sequencer.voices.menu import VoicesMenu from sampletones_application.ui.panels.sequencer.voices.moves import MOVE_DIRECTIONS from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_delete_children, dpg_pointer_within_window +from sampletones_application.utils.gui.dpg import ( + dpg_configure_item, + dpg_delete_children, + dpg_pointer_within_window, +) from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_application.utils.gui.keyboard import ( PRIORITY_PANEL, @@ -85,6 +89,7 @@ def __init__( self._selected_row: Optional[int] = None self._editing_voice_id: Optional[str] = None self._entries: Tuple[VoiceEntryViewModel, ...] = () + self._accepts_voices = False self._tip_new_instrument = self._tooltip(language_manager, SequencerVoicesElements.NEW_INSTRUMENT) self._tip_kind_sample = self._tooltip(language_manager, SequencerVoicesElements.KIND_SAMPLE) self._tip_kind_instrument = self._tooltip(language_manager, SequencerVoicesElements.KIND_INSTRUMENT) @@ -202,11 +207,26 @@ def _create_new_instrument_button(self) -> None: tag=TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, label=self._label(self._language_manager, SequencerVoicesElements.NEW_INSTRUMENT), width=-1, + enabled=self._accepts_voices, callback=lambda: self.call(self.on_new_instrument_requested), ) FontRegistry.bind_to_item(button, Font.REGULAR_SMALL) show_tooltip(button, self._tip_new_instrument) + def set_enabled(self, enabled: bool) -> None: + """Offers the ways a voice comes in while a project stands open to take one. + + The button above the list and the pool section of every menu the list raises follow the + one answer. + """ + self._accepts_voices = enabled + dpg_configure_item(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, enabled=enabled) + + @property + def accepts_voices(self) -> bool: + """Whether a project stands open for a voice to come into.""" + return self._accepts_voices + def _create_voices_table(self) -> None: with ( dpg.child_window( diff --git a/tests/screens/sequencer/voices/test_voices_card.py b/tests/screens/sequencer/voices/test_voices_card.py index 9162f5f90..0852f2b7f 100644 --- a/tests/screens/sequencer/voices/test_voices_card.py +++ b/tests/screens/sequencer/voices/test_voices_card.py @@ -3,8 +3,6 @@ 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 @@ -204,26 +202,51 @@ def open_they_act(screen: Screen) -> None: class TestNewInstrumentWithNoProjectOpen: - """New instrument with no project open keeps the voice list empty, and leaving goes through at once.""" + """With no project open, every way a voice comes in stands greyed out and the voice list stays empty. - @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: - """The voice list stays empty and the exit shortcut ends the application.""" + A new project brings the ways back, and New instrument then adds one voice. + """ + + def test_nothing_is_offered_until_a_project_opens(self, screen: Screen) -> None: + """The card's button, the list's menu and the Voice menu grey out the ways in, and a new project answers.""" voices = screen.sequencer.voices - screen.tabs.bring_to_front(Tab.SEQUENCER) - screen.project.close() - screen.expect(voices.names, operator.not_, description="no voices listed") + menu = screen.context_menu + pool = tuple(screen.words(key) for key in POOL_ACTIONS) - voices.new_instrument() + 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") - screen.frames(SETTLING_FRAMES) - assert voices.names() == [] - screen.press_shortcut(ShortcutId.EXIT) - assert screen.wait_for_exit() + def every_way_in_stands_greyed_out(screen: Screen) -> None: + screen.expect(lambda: not voices.new_instrument_answers(), bool, description="New instrument greyed out") + right_click_the_empty_list(screen) + screen.expect(menu.is_shown, bool, description="the list's menu") + entries = menu.entries() + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") + from_the_bar = [entry for entry in menu_on_the_bar(screen, MenuElements.GROUP_VOICE) if entry.label in pool] + + assert [(entry.label, entry.enabled) for entry in entries] == [(label, False) for label in pool] + assert [(entry.label, entry.enabled) for entry in from_the_bar] == [(label, False) for label in pool] + assert voices.names() == [] + + def a_new_project_takes_a_new_instrument(screen: Screen) -> None: + screen.project.create() + screen.expect(voices.new_instrument_answers, bool, description="New instrument answering") + + voices.new_instrument() + + screen.expect(lambda: len(voices.names()), (1).__eq__, description="one voice") + + screen.scenario( + close_the_project, + every_way_in_stands_greyed_out, + a_new_project_takes_a_new_instrument, + leave_letting_the_project_go, + ).run() class TestASampleAddedFromTheBrowser: diff --git a/tests/suite/screens/views/sequencer.py b/tests/suite/screens/views/sequencer.py index 8840e7d37..6f658e29c 100644 --- a/tests/suite/screens/views/sequencer.py +++ b/tests/suite/screens/views/sequencer.py @@ -89,6 +89,10 @@ def new_instrument(self) -> None: self._hand.scroll_into_view(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT) self._hand.click(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT) + def new_instrument_answers(self) -> bool: + """Whether New instrument above the list answers a press, which it does while a project is open.""" + return self._bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT)).enabled + def names(self) -> List[str]: """The names the rows show, top to bottom.""" diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index c9fc784fb..6a87ac84d 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -1,6 +1,6 @@ from datetime import UTC, datetime from pathlib import Path -from typing import Dict, Final, List, Optional +from typing import Callable, Dict, Final, List, Optional from unittest.mock import MagicMock, patch import pytest @@ -337,6 +337,65 @@ def test_a_file_the_reader_cannot_take_records_no_history( instrument_voices._history.transaction.assert_not_called() +POOL_GESTURES: Final[Dict[str, Callable[[SequencerVoices], None]]] = { + "new instrument": SequencerVoices.add_instrument, + "add sample from file": SequencerVoices.add_sample_from_file, + "import instrument": SequencerVoices.import_instrument, +} + + +@pytest.fixture +def pool_voices() -> SequencerVoices: + """The pool gestures over an open project holding no voice yet.""" + project_controller = MagicMock() + project_controller.is_open = True + project_controller.voice_count = 0 + voices_logic = MagicMock() + voices_logic.read_instrument.return_value = _imported() + return _voices(voices_logic, project_controller, MagicMock(), MagicMock()) + + +class TestAVoiceComesIntoAnOpenProjectAlone: + """Every way a voice comes in asks for an open project as it starts, wherever the gesture came from. + + The menus grey these doors without a project, and a key bound to one reaches it all the same. + """ + + @pytest.mark.parametrize("gesture", POOL_GESTURES.values(), ids=POOL_GESTURES.keys()) + def test_with_no_project_nothing_is_asked_for_and_nothing_added( + self, + pool_voices: SequencerVoices, + located_file: List[Dict[str, object]], + gesture: Callable[[SequencerVoices], None], + ) -> None: + pool_voices._project_controller.is_open = False + + gesture(pool_voices) + + assert located_file == [] + pool_voices._voices_logic.add_new_instrument.assert_not_called() + pool_voices._voices_logic.add_instrument.assert_not_called() + pool_voices._import_reconstruction.assert_not_called() + pool_voices._history.transaction.assert_not_called() + pool_voices._dialogs.show_info.assert_called_once() + + def test_with_a_project_a_new_instrument_joins_the_pool(self, pool_voices: SequencerVoices) -> None: + pool_voices.add_instrument() + + pool_voices._voices_logic.add_new_instrument.assert_called_once() + pool_voices._dialogs.show_info.assert_not_called() + + def test_with_a_project_a_sample_is_asked_for_and_brought_in( + self, + pool_voices: SequencerVoices, + located_file: List[Dict[str, object]], + ) -> None: + pool_voices.add_sample_from_file() + + assert len(located_file) == 1 + pool_voices._import_reconstruction.assert_called_once_with(INSTRUMENT_FILE) + + @pytest.fixture def samples_voices() -> SequencerVoices: """The pool gestures with only the collaborators the samples-menu handlers touch.""" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py index 0d91c6348..86f8817b9 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py @@ -6,7 +6,10 @@ from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import SequencerVoicesElements -from sampletones_application.tags.sequencer import TAG_SEQUENCER_VOICES_PANEL +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, + TAG_SEQUENCER_VOICES_PANEL, +) from sampletones_application.ui.elements import context_menu as context_menu_module from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel @@ -151,6 +154,7 @@ def _panel( footprint_wired: bool = True, instruments: Tuple[Optional[ChannelName], ...] = ONE_INSTRUMENT, channels: Tuple[ChannelName, ...] = NO_CHANNELS, + accepts_voices: bool = True, ) -> VoicesPanelFixture: """A voices panel whose menu builder can run with no DearPyGui context behind it. @@ -166,6 +170,7 @@ def _panel( panel._selected_row = selected_row panel._editing_voice_id = editing panel._list_menu_pending = False + panel._accepts_voices = accepts_voices panel._tab_active = lambda: tab_active panel._router = _Router(field_focused=field_focused) panel.voice_footprint = (lambda _voice_id: footprint) if footprint_wired else None @@ -693,6 +698,49 @@ def test_the_items_ask_for_a_written_voice_and_for_a_located_one( ] +class TestThePoolItemsFollowTheProject: + """The ways a voice comes in answer while a project stands open to take one, as the card's button does.""" + + def test_the_items_answer_while_a_project_is_open( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + _panel(monkeypatch, accepts_voices=True).menu.add_pool_items() + + assert [item.enabled for item in recorder.items] == [True, True, True] + + def test_the_items_are_grayed_out_with_no_project( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + _panel(monkeypatch, accepts_voices=False).menu.add_pool_items() + + assert [item.enabled for item in recorder.items] == [False, False, False] + + @pytest.mark.parametrize("enabled", [True, False]) + def test_the_button_and_the_menus_follow_one_answer( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + enabled: bool, + ) -> None: + configured: List[Tuple[str, bool]] = [] + monkeypatch.setattr( + panel_module, + "dpg_configure_item", + lambda tag, **kwargs: configured.append((tag, kwargs["enabled"])), + ) + fixture = _panel(monkeypatch, accepts_voices=not enabled) + + fixture.panel.set_enabled(enabled) + fixture.menu.add_pool_items() + + assert configured == [(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, enabled)] + assert [item.enabled for item in recorder.items] == [enabled] * len(recorder.items) + + class TestWhichDoorAnswersAPress: """The list and the row are offered the same press, the list first, so one of them answers it.""" From d5c193e28ea390a00363ba1f66995b2561c5a497 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 19:02:01 +0200 Subject: [PATCH 16/84] Fixed: history lines naming a renamed or moved voice one way --- docs/development/bugs-and-todos.md | 3 -- .../logic/sequencer/history_detail.py | 38 +++++++++++-------- .../sequencer/history/test_voice_gestures.py | 7 ---- .../logic/sequencer/test_history_detail.py | 37 ++++++++++++++++-- 4 files changed, 55 insertions(+), 30 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index d9392e3cc..4dbadfb3e 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -187,9 +187,6 @@ currently out of line. An entry leaves when the code meets the contract again. * 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/player/test_source_switch.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/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/song/test_retuning_and_saving.py` reproduces it as a known failure. diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index faf115472..42bdac59c 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -89,7 +89,9 @@ class SequencerHistoryDetail: read as two-digit hex; channels use the ``P``/``p``/``T``/``N`` abbreviations, concatenated when a sample-column gesture spans several channels. A gesture on the voice pool names its voice in the color of the kind that voice is, so a recording - and a hand-written one read apart down the list of entries. + and a hand-written one read apart down the list of entries. Every pool line about a + voice the pool holds names it the same way, by its position and by its name, so a + reader finds the voice in the list whichever gesture the line records. """ def __init__( @@ -263,10 +265,7 @@ def add_instrument(self, name: str) -> Segments: return (self._name(name, VoiceKind.INSTRUMENT),) def remove_voice(self, voice_id: str) -> Segments: - return ( - self._voice(voice_id, colon=True), - self._voice_name(voice_id), - ) + return self._named_voice(voice_id) def replace_sample(self, voice_id: str, name: str) -> Segments: """Describes a reconstruction substitution as the sample's position and the two names. @@ -275,36 +274,36 @@ def replace_sample(self, voice_id: str, name: str) -> Segments: caller builds this detail while the sample still holds the reconstruction being replaced. """ return ( - self._voice(voice_id, colon=True), - self._voice_name(voice_id), + *self._named_voice(voice_id), self._arrow(), self._name(name, VoiceKind.SAMPLE), ) def rename_voice(self, voice_id: str, name: str) -> Segments: - """Describes a rename as the name the voice carries and the one it takes. + """Describes a rename as the voice as it stands and the name it takes. - Both read in the voice's own kind, which the caller builds this detail under while the - pool still holds the name being left behind. + Both names read in the voice's own kind, which the caller builds this detail under while + the pool still holds the name being left behind. """ return ( - self._voice_name(voice_id), + *self._named_voice(voice_id), self._arrow(), self._name(name, self._voices_logic.voice_kind(voice_id)), ) def move_voice(self, voice_id: str, to_index: int) -> Segments: + """Describes a move as the voice where it stands and the position it takes. + + The caller builds this detail while the voice still stands where the move takes it from. + """ return ( - self._voice(voice_id), + *self._named_voice(voice_id), self._arrow(), self._value(display_id(to_index)), ) def duplicate_voice(self, voice_id: str) -> Segments: - return ( - self._voice(voice_id, colon=True), - self._voice_name(voice_id), - ) + return self._named_voice(voice_id) def edit_reconstruction( self, @@ -468,6 +467,13 @@ def _name( ) -> HistoryDetailSegment: return HistoryDetailSegment(text=text, role=_kind_role(kind)) + def _named_voice(self, voice_id: str) -> Segments: + """A voice the pool holds, read as its position and its name in the color of its kind.""" + return ( + self._voice(voice_id, colon=True), + self._voice_name(voice_id), + ) + def _voice_name(self, voice_id: str) -> HistoryDetailSegment: """The name a voice in the pool carries, read in the color of the kind it is.""" return self._name( diff --git a/tests/screens/sequencer/history/test_voice_gestures.py b/tests/screens/sequencer/history/test_voice_gestures.py index f0001abc0..33e607275 100644 --- a/tests/screens/sequencer/history/test_voice_gestures.py +++ b/tests/screens/sequencer/history/test_voice_gestures.py @@ -1,7 +1,5 @@ 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 tests.screens.sequencer.history.cases import GESTURES, Gesture @@ -112,11 +110,6 @@ def test_a_sample_and_an_instrument(self, screen: Screen) -> None: 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: """The entries of a renamed and of a moved sample each name it by its position and by its name.""" history = screen.sequencer.history diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index 10927faaa..00c1f87ba 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -1,4 +1,4 @@ -from typing import List, Tuple +from typing import Callable, List, Tuple from unittest.mock import MagicMock import pytest @@ -352,28 +352,56 @@ def test_replace_sample_shows_position_and_both_names(self) -> None: ("Kick", HistoryDetailRole.SAMPLE), ] - def test_rename_sample_shows_old_and_new(self) -> None: + def test_rename_sample_shows_the_voice_then_its_new_name(self) -> None: controller = _controller() sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") formatter = _formatter(controller) assert _pairs(formatter.rename_voice(sample.id, "Kick")) == [ + ("00:", HistoryDetailRole.SAMPLE), ("Bass", HistoryDetailRole.SAMPLE), (">", HistoryDetailRole.SEPARATOR), ("Kick", HistoryDetailRole.SAMPLE), ] - def test_move_sample_shows_source_position_and_destination(self) -> None: + def test_move_sample_shows_the_voice_then_its_destination(self) -> None: controller = _controller() sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") formatter = _formatter(controller) assert _pairs(formatter.move_voice(sample.id, 5)) == [ - ("00", HistoryDetailRole.SAMPLE), + ("00:", HistoryDetailRole.SAMPLE), + ("Bass", HistoryDetailRole.SAMPLE), (">", HistoryDetailRole.SEPARATOR), ("05", HistoryDetailRole.VALUE), ] + @pytest.mark.parametrize( + "line", + [ + lambda formatter, voice_id: formatter.remove_voice(voice_id), + lambda formatter, voice_id: formatter.replace_sample(voice_id, "Kick"), + lambda formatter, voice_id: formatter.rename_voice(voice_id, "Kick"), + lambda formatter, voice_id: formatter.move_voice(voice_id, 0), + lambda formatter, voice_id: formatter.duplicate_voice(voice_id), + ], + ids=["remove", "replace", "rename", "move", "duplicate"], + ) + def test_every_pool_line_opens_with_the_voice_position_and_name( + self, + line: Callable[[SequencerHistoryDetail, str], Tuple[HistoryDetailSegment, ...]], + ) -> None: + """A reader finds the voice in the list whichever gesture the line records.""" + controller = _controller() + controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Lead") + sample = controller.add_sample(sample_reconstruction([ChannelName.PULSE1]), name="Bass") + formatter = _formatter(controller) + + assert _pairs(line(formatter, sample.id))[:2] == [ + ("01:", HistoryDetailRole.SAMPLE), + ("Bass", HistoryDetailRole.SAMPLE), + ] + def test_value_wraps_a_number(self) -> None: formatter = _formatter(_controller()) @@ -405,6 +433,7 @@ def test_a_written_voice_is_renamed_under_its_own_kind(self) -> None: formatter = _formatter(controller) assert _pairs(formatter.rename_voice(instrument.id, "Strings")) == [ + ("00:", HistoryDetailRole.INSTRUMENT), ("Pad", HistoryDetailRole.INSTRUMENT), (">", HistoryDetailRole.SEPARATOR), ("Strings", HistoryDetailRole.INSTRUMENT), From 00764bb4b7ad6e583465e683cfe0ca96d612a705 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 19:05:53 +0200 Subject: [PATCH 17/84] Fixed: a reconstruction whose file was removed explaining its locked rate as its own --- docs/development/application/stems.md | 2 +- docs/development/bugs-and-todos.md | 3 - docs/guide/reconstruction.md | 2 +- .../logic/reconstruction/reconstruction.py | 24 +++++++- .../tags/reconstructions.py | 6 ++ .../ui/panels/reconstruction/audio.py | 21 ++++++- .../utils/gui/tooltip.py | 12 +++- .../view_model/reconstruction/rate.py | 12 ++++ .../reconstruction/reconstruction.py | 16 +++--- src/sampletones_config/lang/en.yaml | 1 + .../vanished/test_reconstruction_removed.py | 20 +++++-- .../reconstruction/test_reconstruction.py | 54 ++++++++++++++++++ .../panels/reconstruction/test_audio_panel.py | 56 +++++++++++++++---- .../ui/panels/reconstruction/test_plot.py | 1 + .../reconstruction/test_reconstruction.py | 27 +++++---- 15 files changed, 208 insertions(+), 49 deletions(-) create mode 100644 src/sampletones_application/view_model/reconstruction/rate.py diff --git a/docs/development/application/stems.md b/docs/development/application/stems.md index 65496afeb..657ce9be4 100644 --- a/docs/development/application/stems.md +++ b/docs/development/application/stems.md @@ -14,7 +14,7 @@ Opening the document loads the recorded stems through `load_recordings`, at the A document on disk is named after its file. The converter names that file from every recording it read, through the naming rules in `sampletones_core.reconstructions.naming`, so the name stands when a recording later leaves the document. A document held in memory, such as a project sample, carries the name it was given. -The Stems card names every recorded path, one row per stem. Each row has its own full-path tooltip and reveals its recording on a double-click. The Audio source panel keeps the reconstruction's own file, the choice between the two waveforms and the engine rate, which a document living on disk retimes as a step of its edits and a project sample leaves to the project. Locating reveals every recorded path at once, in one window with every stem selected where the file manager supports that, and in one window per directory otherwise. +The Stems card names every recorded path, one row per stem. Each row has its own full-path tooltip and reveals its recording on a double-click. The Audio source panel keeps the reconstruction's own file, the choice between the two waveforms and the engine rate, which a document living on disk retimes as a step of its edits. A project sample leaves the rate to the project, and a document with no file keeps it until it is saved to one. The logic names which of the two locks the rate, and the panel's hint says that reason. Locating reveals every recorded path at once, in one window with every stem selected where the file manager supports that, and in one window per directory otherwise. ## The stems card diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 41f74f27d..bd9d92316 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -159,9 +159,6 @@ currently out of line. An entry leaves when the code meets the contract again. 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/scan/test_reading_a_folder.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/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/open_voice/test_open_voice.py` diff --git a/docs/guide/reconstruction.md b/docs/guide/reconstruction.md index 03531e859..7bd0c4a5f 100644 --- a/docs/guide/reconstruction.md +++ b/docs/guide/reconstruction.md @@ -22,7 +22,7 @@ changes away. **Cancel** keeps the one you have open. The **Source** card switches playback between **Reconstruction** and **Original audio**, so you can compare the two. Its **NES frequency** field retimes the reconstruction: type a new rate and press `Enter`. The field is locked for a reconstruction that belongs to a project, which follows the project's -rate. +rate. It is also locked for a reconstruction whose file is gone, until you save it to a file. The **Waveform** card has a checkbox for each channel, and keys `1` to `4` switch the same checkboxes. diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index d076b427d..d36ad1aaa 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -29,6 +29,7 @@ from sampletones_application.view_model.reconstruction.paths.state import ( ReconstructionPathState, ) +from sampletones_application.view_model.reconstruction.rate import RateLock from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) @@ -217,8 +218,23 @@ def _build_view_model( reconstruction_file=reconstruction_file, original_audio=original_audio, nes_frequency=reconstruction_data.config.nes_frequency, + rate_lock=self._rate_lock(reconstruction_data), ) + def _rate_lock(self, reconstruction_data: ReconstructionData) -> Optional[RateLock]: + """Why the open document keeps its rate, or ``None`` while the tab may retime it. + + A sample of the project follows the project's rate. A document with no file keeps its + rate until it is saved to one. + """ + if self._reconstruction_manager.is_project_sample: + return RateLock.PROJECT_SAMPLE + + if reconstruction_data.filepath is None: + return RateLock.NO_FILE + + return None + def close_reconstruction(self) -> None: self._current_audio_source = AudioSourceType.RECONSTRUCTION self._playing_channels = frozenset() @@ -245,6 +261,7 @@ def close_reconstruction(self) -> None: reconstruction_file=empty_path, original_audio=empty_path, nes_frequency=None, + rate_lock=None, ), ) @@ -720,9 +737,10 @@ def _build_path_view_models( """Resolves the reconstruction-file and original-audio locations for display. Each location is reported independently. A file-backed reconstruction knows its own file; - a detached one (a project sample) reports not-applicable. Its source audio is available when - the recorded file loaded, not-found when a path is recorded yet its content is unavailable, - and not-applicable when the reconstruction has been detached from its origin. + one with no file, such as a project sample or a document whose file was removed, reports + not-applicable. Its source audio is available when the recorded file loaded, not-found when + a path is recorded yet its content is unavailable, and not-applicable when the + reconstruction has been detached from its origin. """ reconstruction_file = self._build_file_path_view_model(reconstruction_data.filepath) original_audio = self._build_audio_path_view_model( diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index 6f27937d8..a36b22b87 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -128,6 +128,12 @@ Widget.TOOLTIP, "nes_frequency_locked", ) +TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY_LOCKED = TagName( + Page.RECONSTRUCTIONS, + Panel.RECONSTRUCTION, + Widget.TEXT, + "nes_frequency_locked", +) TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_STEMS = TagName( Page.RECONSTRUCTIONS, Panel.RECONSTRUCTION, diff --git a/src/sampletones_application/ui/panels/reconstruction/audio.py b/src/sampletones_application/ui/panels/reconstruction/audio.py index bdc30650e..71002f969 100644 --- a/src/sampletones_application/ui/panels/reconstruction/audio.py +++ b/src/sampletones_application/ui/panels/reconstruction/audio.py @@ -13,6 +13,7 @@ TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO, TAG_RECONSTRUCTIONS_RECONSTRUCTION_PATH_RECONSTRUCTION_FILE, TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY_LOCKED, TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY, TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, ) @@ -31,6 +32,7 @@ from sampletones_application.view_model.reconstruction.paths.state import ( ReconstructionPathState, ) +from sampletones_application.view_model.reconstruction.rate import RateLock from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) @@ -45,7 +47,8 @@ class GUIReconstructionAudioPanel(GUIPanel): The card names the reconstruction's own file and takes the engine rate it runs at, and offers the choice between the reconstruction and the audio it was built from. The recordings behind that audio are named by the stems card, one row each. A reconstruction that is a - sample of a project follows the project's rate, so its field is locked. + sample of a project follows the project's rate, and one with no file keeps its rate until it + is saved to one, so the field is locked for both, with a hint naming which. """ def __init__( @@ -190,8 +193,9 @@ def _create_frequency_input(self) -> None: ) attach_disabled_tooltip( TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_NES_FREQUENCY, - self._language_manager["reconstructions.reconstruction.tooltip.nes_frequency_locked"], + self._rate_lock_words(RateLock.PROJECT_SAMPLE), tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, + text_tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY_LOCKED, ) def _render_frequency(self, view_model: ReconstructionViewModel) -> None: @@ -213,9 +217,22 @@ def _render_frequency(self, view_model: ReconstructionViewModel) -> None: TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, view_model.show_nes_frequency_hint, ) + if view_model.rate_lock is not None: + dpg_set_value( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY_LOCKED, + self._rate_lock_words(view_model.rate_lock), + ) if nes_frequency is not None and not dpg.is_item_active(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY): dpg_set_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, nes_frequency) + def _rate_lock_words(self, rate_lock: RateLock) -> str: + """What the locked rate's hint says, which names the reason the rate is kept.""" + match rate_lock: + case RateLock.PROJECT_SAMPLE: + return self._language_manager["reconstructions.reconstruction.tooltip.nes_frequency_locked"] + case RateLock.NO_FILE: + return self._language_manager["reconstructions.reconstruction.tooltip.nes_frequency_no_file"] + def _on_nes_frequency_input(self, _sender: Sender, _app_data: int) -> None: self.call( self.on_nes_frequency_changed, diff --git a/src/sampletones_application/utils/gui/tooltip.py b/src/sampletones_application/utils/gui/tooltip.py index 0bbb54886..f995fdfb8 100644 --- a/src/sampletones_application/utils/gui/tooltip.py +++ b/src/sampletones_application/utils/gui/tooltip.py @@ -68,14 +68,22 @@ def attach_disabled_tooltip( message: str, *, tag: str, + text_tag: Optional[str] = None, ) -> None: """Attaches an explanatory tooltip to ``parent`` — an enabled group wrapping a control that can be disabled — and hides it by default. Toggle ``tag``'s ``show`` to reveal the explanation while the control is unavailable. The wrapper group is the hover target because DearPyGui surfaces a tooltip - for an enabled item, so the explanation reaches the user even when the inner control is disabled.""" + for an enabled item, so the explanation reaches the user even when the inner control is disabled. + + ``text_tag`` names the message itself, which is what a caller gives it where the reason the + control is unavailable changes while the control stands.""" + text_kwargs: SerializedData = {} + if text_tag is not None: + text_kwargs["tag"] = text_tag + with dpg.tooltip(parent, tag=tag, show=False, hide_on_activity=True) as tooltip: ThemeRegistry.get(TAG_GLOBAL_THEME_TOOLTIP).bind_to_item(tooltip) - tooltip_text = dpg.add_text(message) + tooltip_text = dpg.add_text(message, **text_kwargs) FontRegistry.bind_to_item(tooltip_text, Font.REGULAR_SMALL) diff --git a/src/sampletones_application/view_model/reconstruction/rate.py b/src/sampletones_application/view_model/reconstruction/rate.py new file mode 100644 index 000000000..e6b40418d --- /dev/null +++ b/src/sampletones_application/view_model/reconstruction/rate.py @@ -0,0 +1,12 @@ +from enum import StrEnum + + +class RateLock(StrEnum): + """Why the open reconstruction's NES frequency is locked, which the Source card explains. + + A sample of the project runs at the rate the project sets. A reconstruction with no file keeps + its rate until it is saved to one. + """ + + PROJECT_SAMPLE = "project_sample" + NO_FILE = "no_file" diff --git a/src/sampletones_application/view_model/reconstruction/reconstruction.py b/src/sampletones_application/view_model/reconstruction/reconstruction.py index 880906c1b..e322cd9f1 100644 --- a/src/sampletones_application/view_model/reconstruction/reconstruction.py +++ b/src/sampletones_application/view_model/reconstruction/reconstruction.py @@ -10,6 +10,7 @@ RECORDED_PATH_STATES, ReconstructionPathState, ) +from .rate import RateLock class ReconstructionViewModel(BaseModel, frozen=True): @@ -20,8 +21,8 @@ class ReconstructionViewModel(BaseModel, frozen=True): switched on, so a channel switched off by hand stays off across an edit. :attr:`nes_frequency` is the engine rate the open reconstruction runs at, and ``None`` - while the tab holds no document. A document living on disk takes a new rate from the tab, - while one detached from its file is a project sample and follows the project's rate. + while the tab holds no document. :attr:`rate_lock` says why the tab may not change that rate, + and is ``None`` for a document living on disk, which takes a new rate from the tab. """ reconstruction_loaded: bool @@ -30,6 +31,7 @@ class ReconstructionViewModel(BaseModel, frozen=True): reconstruction_file: ReconstructionPathViewModel original_audio: ReconstructionPathViewModel nes_frequency: Optional[int] + rate_lock: Optional[RateLock] @property def audio_source_enabled(self) -> bool: @@ -39,15 +41,13 @@ def audio_source_enabled(self) -> bool: @property def nes_frequency_editable(self) -> bool: - """The rate can be changed while a loaded document still has a file it is saved to.""" - return ( - self.reconstruction_loaded and self.reconstruction_file.state is not ReconstructionPathState.NOT_APPLICABLE - ) + """The rate can be changed while a loaded document has no reason to keep it.""" + return self.reconstruction_loaded and self.rate_lock is None @property def show_nes_frequency_hint(self) -> bool: - """The hint explains the locked rate, so it appears exactly when a loaded document follows the project's.""" - return self.reconstruction_loaded and not self.nes_frequency_editable + """The hint explains the locked rate, so it appears exactly when a loaded document's rate is locked.""" + return self.reconstruction_loaded and self.rate_lock is not None @property def locate_audio_enabled(self) -> bool: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index a5d0c7f1f..dd6242987 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -501,6 +501,7 @@ reconstructions.reconstruction.label.autoscale_checkbox: "Autoscale" reconstructions.reconstruction.label.reconstruction_file_label: "Reconstruction file:" reconstructions.reconstruction.label.nes_frequency_label: "NES frequency:" reconstructions.reconstruction.tooltip.nes_frequency_locked: "This reconstruction is a sample of the project, which sets its NES frequency." +reconstructions.reconstruction.tooltip.nes_frequency_no_file: "This reconstruction has no file. Save it to a file to change its NES frequency." reconstructions.reconstruction.label.path_not_found: "not found" reconstructions.reconstruction.label.path_not_applicable: "N/A" reconstructions.reconstruction.label.original_audio_radio: "Original audio" diff --git a/tests/screens/prompts/vanished/test_reconstruction_removed.py b/tests/screens/prompts/vanished/test_reconstruction_removed.py index f3c3c0345..9594d52c0 100644 --- a/tests/screens/prompts/vanished/test_reconstruction_removed.py +++ b/tests/screens/prompts/vanished/test_reconstruction_removed.py @@ -23,6 +23,7 @@ SETTLING_FRAMES: Final[int] = 10 NOT_APPLICABLE: Final[str] = "reconstructions.reconstruction.label.path_not_applicable" RATE_SET_BY_THE_PROJECT: Final[str] = "reconstructions.reconstruction.tooltip.nes_frequency_locked" +RATE_KEPT_WITHOUT_A_FILE: Final[str] = "reconstructions.reconstruction.tooltip.nes_frequency_no_file" def take_its_file_away(screen: Screen) -> None: @@ -115,16 +116,23 @@ def save_where_asked_writes_it_and_closes(screen: Screen) -> None: 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: - """The rate stays locked and its explanation words speak of the reconstruction, not of a project.""" + """The rate stays locked and its explanation words speak of the missing file, not of a project. + + Closing the reconstruction afterwards lets its changes go, so the application leaves with + nothing to ask. + """ reconstructions = screen.reconstructions + prompt = reconstructions.unsaved_prompt 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) + assert reconstructions.retune_lock_words() == screen.words(RATE_KEPT_WITHOUT_A_FILE) + + reconstructions.close_from_menu() + screen.expect(prompt.is_shown, bool, description="the question about closing") + prompt.confirm() + + screen.expect(screen.title, titled(screen).__eq__, description="the reconstruction let go") diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index cdc69a5bb..c79809533 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -20,6 +20,7 @@ from sampletones_application.view_model.reconstruction.paths.state import ( ReconstructionPathState, ) +from sampletones_application.view_model.reconstruction.rate import RateLock from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) @@ -89,6 +90,7 @@ def mock_export_service() -> MagicMock: def mock_reconstruction_manager() -> MagicMock: mock = MagicMock(spec=ReconstructionManager) mock.current_reconstruction = None + mock.is_project_sample = False mock.audio_filepath = None mock.listening = StemListening() mock.refresh_features.side_effect = lambda: _refresh_features(mock) @@ -496,6 +498,58 @@ def test_a_closed_tab_states_no_rate( assert received[0].nes_frequency is None +class TestTheRateLock: + """The view names why the open document keeps its rate: it is a sample of the project, or it has no file.""" + + @staticmethod + def _shown(panel_logic: ReconstructionPanelLogic) -> ReconstructionViewModel: + received: List[ReconstructionViewModel] = [] + panel_logic.on_view_changed = received.append + panel_logic.display_reconstruction() + return received[-1] + + def test_a_document_on_disk_is_free_to_retime( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + tmp_path: Path, + ) -> None: + _open(mock_reconstruction_manager, loaded_data.detached_copy(tmp_path / "standing.stn")) + + assert self._shown(panel_logic).rate_lock is None + + def test_a_document_with_no_file_keeps_its_rate_for_that_reason( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + """A document whose file was taken away belongs to no project, so its lock says so.""" + _open(mock_reconstruction_manager, loaded_data) + + assert self._shown(panel_logic).rate_lock is RateLock.NO_FILE + + def test_a_sample_of_the_project_follows_the_projects_rate( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.is_project_sample = True + _open(mock_reconstruction_manager, loaded_data) + + assert self._shown(panel_logic).rate_lock is RateLock.PROJECT_SAMPLE + + def test_a_closed_tab_names_no_lock(self, panel_logic: ReconstructionPanelLogic) -> None: + received: List[ReconstructionViewModel] = [] + panel_logic.on_view_changed = received.append + + panel_logic.close_reconstruction() + + assert received[-1].rate_lock is None + + class TestAReTimedDocumentIsShown: """A document re-timed to another rate is shown at it, every reading of it re-answered. diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py index ec1117749..722eeaedb 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_audio_panel.py @@ -17,6 +17,7 @@ from sampletones_application.tags.general import SUF_HANDLER_REGISTRY from sampletones_application.tags.reconstructions import ( TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, + TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY_LOCKED, TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY, TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED, ) @@ -37,6 +38,7 @@ from sampletones_application.view_model.reconstruction.paths.state import ( ReconstructionPathState, ) +from sampletones_application.view_model.reconstruction.rate import RateLock from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) @@ -91,10 +93,11 @@ def rendered_panel(panel: GUIReconstructionAudioPanel) -> GUIReconstructionAudio def _view_model( nes_frequency: Optional[int], *, - file_state: ReconstructionPathState = ReconstructionPathState.AVAILABLE, + rate_lock: Optional[RateLock], ) -> ReconstructionViewModel: empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=()) loaded = nes_frequency is not None + file_state = ReconstructionPathState.AVAILABLE if rate_lock is None else ReconstructionPathState.NOT_APPLICABLE return ReconstructionViewModel( reconstruction_loaded=loaded, playing_channels=frozenset(), @@ -102,9 +105,15 @@ def _view_model( reconstruction_file=(ReconstructionPathViewModel(state=file_state, paths=()) if loaded else empty_path), original_audio=empty_path, nes_frequency=nes_frequency, + rate_lock=rate_lock, ) +def _lock_words() -> str: + """What the locked rate's hint reads.""" + return str(dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_NES_FREQUENCY_LOCKED)) + + def _commit(rendered_panel: GUIReconstructionAudioPanel, value: int) -> None: """Types a value into the field and finishes the edit the way DearPyGui reports it.""" dpg.set_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY, value) @@ -122,7 +131,7 @@ def test_a_loaded_reconstruction_states_its_rate( self, rendered_panel: GUIReconstructionAudioPanel, ) -> None: - rendered_panel.update_view(_view_model(PAL_FREQUENCY)) + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=None)) assert dpg.get_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY) == PAL_FREQUENCY @@ -130,8 +139,8 @@ def test_an_empty_tab_shows_no_field( self, rendered_panel: GUIReconstructionAudioPanel, ) -> None: - rendered_panel.update_view(_view_model(PAL_FREQUENCY)) - rendered_panel.update_view(_view_model(None)) + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=None)) + rendered_panel.update_view(_view_model(None, rate_lock=None)) assert not dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY) @@ -147,29 +156,54 @@ def test_a_document_with_a_file_takes_a_new_rate( self, rendered_panel: GUIReconstructionAudioPanel, ) -> None: - rendered_panel.update_view(_view_model(PAL_FREQUENCY)) + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=None)) assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)["enabled"] assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY)["show"] assert not dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED)["show"] - def test_a_document_detached_from_its_file_locks_the_field_and_says_why( + @pytest.mark.parametrize( + ("rate_lock", "words_key"), + [ + (RateLock.PROJECT_SAMPLE, "reconstructions.reconstruction.tooltip.nes_frequency_locked"), + (RateLock.NO_FILE, "reconstructions.reconstruction.tooltip.nes_frequency_no_file"), + ], + ids=lambda value: str(value), + ) + def test_a_locked_document_locks_the_field_and_says_why( self, rendered_panel: GUIReconstructionAudioPanel, + rate_lock: RateLock, + words_key: str, ) -> None: - rendered_panel.update_view(_view_model(PAL_FREQUENCY, file_state=ReconstructionPathState.NOT_APPLICABLE)) + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=rate_lock)) assert dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY) assert not dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)["enabled"] assert not dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY)["show"] assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY_LOCKED)["show"] + assert _lock_words() == LanguageManager(LANG_EN)[words_key] + + def test_a_document_with_no_file_says_nothing_of_a_project( + self, + rendered_panel: GUIReconstructionAudioPanel, + ) -> None: + """A sample of a project opened first leaves no project words behind for the next document.""" + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=RateLock.PROJECT_SAMPLE)) + project_words = _lock_words() + + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=RateLock.NO_FILE)) + + assert _lock_words() != project_words + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=RateLock.PROJECT_SAMPLE)) + assert _lock_words() == project_words def test_the_lock_lifts_when_a_document_with_a_file_opens( self, rendered_panel: GUIReconstructionAudioPanel, ) -> None: - rendered_panel.update_view(_view_model(PAL_FREQUENCY, file_state=ReconstructionPathState.NOT_APPLICABLE)) - rendered_panel.update_view(_view_model(NTSC_FREQUENCY)) + rendered_panel.update_view(_view_model(PAL_FREQUENCY, rate_lock=RateLock.NO_FILE)) + rendered_panel.update_view(_view_model(NTSC_FREQUENCY, rate_lock=None)) assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_INPUT_NES_FREQUENCY)["enabled"] assert dpg.get_item_configuration(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TOOLTIP_NES_FREQUENCY)["show"] @@ -181,7 +215,7 @@ def test_a_finished_edit_reports_the_rate_typed( ) -> None: reported: List[int] = [] rendered_panel.on_nes_frequency_changed = reported.append - rendered_panel.update_view(_view_model(NTSC_FREQUENCY)) + rendered_panel.update_view(_view_model(NTSC_FREQUENCY, rate_lock=None)) _commit(rendered_panel, PAL_FREQUENCY) @@ -193,7 +227,7 @@ def test_a_rate_out_of_range_is_reported_at_the_nearest_bound( ) -> None: reported: List[int] = [] rendered_panel.on_nes_frequency_changed = reported.append - rendered_panel.update_view(_view_model(NTSC_FREQUENCY)) + rendered_panel.update_view(_view_model(NTSC_FREQUENCY, rate_lock=None)) _commit(rendered_panel, MAX_NES_FREQUENCY + 1) diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py index 289aef45d..24f547dcd 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py @@ -111,6 +111,7 @@ def _view_model( reconstruction_file=empty_path, original_audio=empty_path, nes_frequency=None, + rate_lock=None, ) diff --git a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py index 736f51dd4..1422c7883 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from pathlib import Path +from typing import Optional import pytest @@ -9,6 +10,7 @@ from sampletones_application.view_model.reconstruction.paths.state import ( ReconstructionPathState, ) +from sampletones_application.view_model.reconstruction.rate import RateLock from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionViewModel, ) @@ -105,6 +107,7 @@ def test_enablement_follows_original_audio_state( reconstruction_file=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=()), original_audio=ReconstructionPathViewModel(state=case.original_audio_state, paths=()), nes_frequency=None, + rate_lock=None, ) assert view_model.audio_source_enabled is case.audio_source_enabled @@ -113,10 +116,11 @@ def test_enablement_follows_original_audio_state( class TestReconstructionViewModelNesFrequency: - """The rate is the tab's to change while the document has a file, and the project's once detached.""" + """The rate is the tab's to change while nothing locks it, and a lock always comes with its hint.""" @staticmethod - def _view_model(*, loaded: bool, file_state: ReconstructionPathState) -> ReconstructionViewModel: + def _view_model(*, loaded: bool, rate_lock: Optional[RateLock]) -> ReconstructionViewModel: + file_state = ReconstructionPathState.AVAILABLE if loaded else ReconstructionPathState.EMPTY return ReconstructionViewModel( reconstruction_loaded=loaded, playing_channels=frozenset(), @@ -124,27 +128,26 @@ def _view_model(*, loaded: bool, file_state: ReconstructionPathState) -> Reconst reconstruction_file=ReconstructionPathViewModel(state=file_state, paths=()), original_audio=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, paths=()), nes_frequency=None, + rate_lock=rate_lock, ) - @pytest.mark.parametrize( - "file_state", - [ReconstructionPathState.AVAILABLE, ReconstructionPathState.NOT_FOUND], - ids=lambda state: state.name.lower(), - ) - def test_a_document_with_a_file_takes_a_new_rate(self, file_state: ReconstructionPathState) -> None: - view_model = self._view_model(loaded=True, file_state=file_state) + def test_a_document_nothing_locks_takes_a_new_rate(self) -> None: + view_model = self._view_model(loaded=True, rate_lock=None) assert view_model.nes_frequency_editable assert not view_model.show_nes_frequency_hint - def test_a_document_detached_from_its_file_follows_the_project(self) -> None: - view_model = self._view_model(loaded=True, file_state=ReconstructionPathState.NOT_APPLICABLE) + @pytest.mark.parametrize("rate_lock", list(RateLock), ids=lambda rate_lock: rate_lock.value) + def test_a_locked_document_keeps_its_rate_and_explains_it(self, rate_lock: RateLock) -> None: + """A project sample and a document with no file are both locked, each for its own reason.""" + view_model = self._view_model(loaded=True, rate_lock=rate_lock) assert not view_model.nes_frequency_editable assert view_model.show_nes_frequency_hint + assert view_model.rate_lock is rate_lock def test_an_empty_tab_takes_and_explains_nothing(self) -> None: - view_model = self._view_model(loaded=False, file_state=ReconstructionPathState.EMPTY) + view_model = self._view_model(loaded=False, rate_lock=None) assert not view_model.nes_frequency_editable assert not view_model.show_nes_frequency_hint From aef178a39df938d00cb46aa5dff68fe2945e11e7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 19:16:31 +0200 Subject: [PATCH 18/84] Fixed: a missing recording leaving the reconstruction alone on the waveform --- docs/development/application/stems.md | 2 +- docs/development/bugs-and-todos.md | 3 - .../logic/reconstruction/data.py | 13 +++- .../logic/reconstruction/reconstruction.py | 4 +- .../ui/elements/graphs/waveform.py | 8 +-- .../player/test_source_switch.py | 5 -- .../logic/reconstruction/test_data.py | 63 +++++++++++++++++++ .../ui/elements/graphs/test_waveform.py | 45 +++++++++++++ 8 files changed, 126 insertions(+), 17 deletions(-) diff --git a/docs/development/application/stems.md b/docs/development/application/stems.md index 657ce9be4..de852b4eb 100644 --- a/docs/development/application/stems.md +++ b/docs/development/application/stems.md @@ -36,7 +36,7 @@ The reconstruction tab's Stems card turns the recorded assignment into a listene ### What the code guarantees -A filtered mix keeps every array at its unfiltered length, so it aligns with the unfiltered one sample for sample. The filter zeroes the unselected frames per channel before mixing (`filter_approximations`), and the original mix covers the recordings of the stems heard on any channel. The panel logic holds the channels each stem is heard on and re-answers the stems view model, the waveform and the audio data whenever the choice changes. A reconstruction that records one source presents a single row for its recording, and one that records no source shows the card's empty state. +A filtered mix keeps every array at its unfiltered length, so it aligns with the unfiltered one sample for sample. The filter zeroes the unselected frames per channel before mixing (`filter_approximations`), and the original mix covers the recordings of the stems heard on any channel. It is silence while no stem is heard, and there is none while no recording loaded. The panel logic holds the channels each stem is heard on and re-answers the stems view model, the waveform and the audio data whenever the choice changes. A reconstruction that records one source presents a single row for its recording, and one that records no source shows the card's empty state. Removal runs through `without_stem`, which returns a fresh reconstruction holding what the rules in [Editing a stems reconstruction](#editing-a-stems-reconstruction) leave. A confirmed removal is a step of the open document's edits, taken after the edits made before it ([Editing the open reconstruction](reconstruction-edits.md)), and it lands as a `ReconstructionEdit`, the payload both a regenerated instrument and a removed recording travel as. One path therefore rebinds the open document and records the edit against the project history. They differ in what the instruments panel does. A regenerated instrument carries the envelopes the panel's own edit wrote, so the panel keeps drawing them. A removal redraws every channel from the document it leaves. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index bd9d92316..d8f57f06a 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -170,9 +170,6 @@ currently out of line. An entry leaves when the code meets the contract again. on the first brings the second, and so does a close made twice while an edit is on its way. `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/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/instruments/test_note_keys.py` diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index 64923027e..adb9b4c62 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -168,8 +168,17 @@ def _stem_recording_indexes(self) -> Dict[int, int]: stems_data = self.reconstruction.stems_data return {entry.id: index for index, entry in enumerate(stems_data.config.entries)} - def original_mix_for(self, selection: StemSelection) -> np.ndarray: - """The original audio of the stems heard anywhere, silence once none are.""" + def original_mix_for(self, selection: StemSelection) -> Optional[np.ndarray]: + """The original audio of the stems heard anywhere. + + Returns: + Optional[np.ndarray]: The mix of the recordings heard, silence where the recordings + loaded and none of them is heard, and ``None`` where no recording loaded, which leaves + the approximation on its own in playback and the display. + """ + if not self.stem_audios: + return None + selected_stem_ids = selection.any_channel() indexes = self._stem_recording_indexes recordings = [self.stem_audios[index] for stem_id, index in indexes.items() if stem_id in selected_stem_ids] diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index d36ad1aaa..e02aaf159 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -718,10 +718,10 @@ def _compute_audio_data(self) -> Optional[AudioData]: sample_rate = reconstruction_data.reconstruction.config.sample_rate if self._current_audio_source == AudioSourceType.ORIGINAL: - if reconstruction_data.original_audio is None: + selected_original_audio = reconstruction_data.original_mix_for(self._stem_selection) + if selected_original_audio is None: return None - selected_original_audio = reconstruction_data.original_mix_for(self._stem_selection) return AudioData.from_array(selected_original_audio, sample_rate) partial_approximation = reconstruction_data.partials_for( diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index eebd88568..6057dd07b 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -502,15 +502,15 @@ def update_waveform_data( ) -> None: """Redraws the loaded waveform from fresh data, keeping the view the reader left it at. - ``refit`` names the update a retune is: the audio's own length changed, so the view is - re-fitted to the new span rather than held at a position the old one no longer answers to. + The plot draws the layers the fresh data displays, so a layer the data no longer carries + leaves it. ``refit`` names the update a retune is: the audio's own length changed, so the + view is re-fitted to the new span. """ if not isinstance(self.current_data, WaveformData): return self.current_data = waveform_data - for layer in self._display_layers(waveform_data, selected_channels): - self.layers[layer.name] = layer + self.layers = {layer.name: layer for layer in self._display_layers(waveform_data, selected_channels)} if refit: self._update_ranges() diff --git a/tests/screens/reconstructions/player/test_source_switch.py b/tests/screens/reconstructions/player/test_source_switch.py index d96c4a198..8b521d0e1 100644 --- a/tests/screens/reconstructions/player/test_source_switch.py +++ b/tests/screens/reconstructions/player/test_source_switch.py @@ -96,11 +96,6 @@ def the_switch_offers_nothing_else(screen: Screen) -> None: 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 diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 39af17c93..f1ac2421e 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -416,6 +416,69 @@ def test_a_single_source_with_no_selection_is_silence( np.testing.assert_allclose(data.original_mix_for(_heard(0)), data.original_audio) +class TestTheOriginalAMissingRecordingLeaves: + """With no recording loaded there is no original to mix, so the approximation stands alone.""" + + @staticmethod + def _missing_a_recording( + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> ReconstructionData: + """A document recorded from two takes, the second of which is gone, which costs the whole original.""" + first = tmp_path / "kick.wav" + write_wave(first, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5) + reconstruction = recorded_from(reconstruction_factory(), (first, tmp_path / "gone.wav")) + return ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + def test_no_recording_mixes_into_no_original( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + data = self._missing_a_recording(reconstruction_factory, tmp_path) + + assert data.original_mix_for(_heard(0, 1)) is None + assert data.original_mix_for(_heard()) is None + + def test_the_heard_waveform_carries_no_original_line( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + data = self._missing_a_recording(reconstruction_factory, tmp_path) + + assert data.waveform_data(_heard(0, 1)).original_audio is None + + def test_a_detached_document_mixes_into_no_original( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + reconstruction = reconstruction_factory() + reconstruction.detach_source() + + data = ReconstructionData.from_reconstruction(reconstruction, name="Sample") + + assert data.original_mix_for(_heard(0)) is None + + def test_a_loaded_recording_heard_nowhere_is_a_silent_line( + self, + reconstruction_factory: Callable[[], Reconstruction], + tmp_path: Path, + ) -> None: + """A loaded recording the reader switched off still has an original, which is silence.""" + source_audio = tmp_path / "source.wav" + write_wave(source_audio, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5) + data = ReconstructionData.from_reconstruction( + recorded_from(reconstruction_factory(), (source_audio,)), + name="Sample", + ) + + original = data.waveform_data(_heard()).original_audio + + assert original is not None + np.testing.assert_array_equal(original, np.zeros_like(data.reconstruction.approximation)) + + class TestRebindingToAnEditedReconstruction: def _three_recordings( self, diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py index 51f3d7843..0180c36bd 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -214,6 +214,51 @@ def test_a_graph_not_yet_holding_waveform_data_takes_no_update( ranges.assert_not_called() +class TestAnUpdateDrawsTheLayersItsDataDisplays: + """An update draws what the fresh data displays, so a layer the data no longer carries leaves the plot.""" + + @staticmethod + def _drawing_both(fake_dpg: _FakeDPG) -> GUIWaveformGraph: + """A graph showing the original and the reconstruction, each with its series on the axis.""" + graph = _graph() + graph.current_data = TestWaveformDataUpdateRefit._waveform_data() + graph.layers = {"Original": _Layer("Original"), "Reconstruction": _Layer("Reconstruction")} + fake_dpg.set_children( + "axis", + [graph._series_tag("Original"), graph._series_tag("Reconstruction"), "indicator", "overlay"], + ) + return graph + + def test_a_layer_the_data_no_longer_displays_leaves_with_its_series( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph = self._drawing_both(fake_dpg) + original_series = fake_dpg.alias_to_id[graph._series_tag("Original")] + monkeypatch.setattr(graph, "_display_layers", lambda *_args, **_kwargs: [_Layer("Reconstruction")]) + + graph.update_waveform_data(TestWaveformDataUpdateRefit._waveform_data()) + + assert (list(graph.layers), fake_dpg.deleted) == (["Reconstruction"], [original_series]) + + def test_layers_the_data_still_displays_stay( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + graph = self._drawing_both(fake_dpg) + monkeypatch.setattr( + graph, + "_display_layers", + lambda *_args, **_kwargs: [_Layer("Original"), _Layer("Reconstruction")], + ) + + graph.update_waveform_data(TestWaveformDataUpdateRefit._waveform_data()) + + assert (list(graph.layers), fake_dpg.deleted) == (["Original", "Reconstruction"], []) + + class TestWaveformReconstructionDim: def test_series_color_is_untouched_when_not_dimmed(self) -> None: graph = _graph() From bfd8b229537f7dc66b9e9536a980a37bf6f74054 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 19:31:48 +0200 Subject: [PATCH 19/84] Fixed: held callbacks reading rows a rebuild took away --- docs/development/application/render-thread.md | 5 ++ docs/development/bugs-and-todos.md | 4 -- .../ui/elements/stems/gestures.py | 21 ++++--- .../ui/elements/tree/tree.py | 21 ++++--- .../ui/panels/main/source/rows.py | 11 +++- .../ui/panels/sequencer/order/panel.py | 8 ++- .../ui/panels/sequencer/tracker/panel.py | 12 ++-- .../ui/panels/sequencer/voices/panel.py | 7 ++- src/sampletones_application/utils/gui/dpg.py | 11 ++++ .../keybindings/test_rebinding_undo.py | 2 - .../sequencer/colors/test_kind_colors.py | 3 +- .../sequencer/history/test_voice_gestures.py | 3 - .../sequencer/voices/test_voices_card.py | 3 +- tests/suite/screens/steps/exports.py | 2 - tests/suite/screens/steps/sequencer.py | 9 --- .../ui/panels/sequencer/voices/test_hover.py | 57 ++++++++++++++++++- .../ui/panels/sequencer/voices/test_menu.py | 2 +- .../utils/gui/test_dpg.py | 29 +++++++++- 18 files changed, 154 insertions(+), 56 deletions(-) diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index c8e3896b7..7a1c783f5 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -47,6 +47,11 @@ the top of each frame's drain. A gesture therefore reaches the interface from th What a gesture costs is paid between frames. A callback heavy enough to be felt should spread its work across frames itself. +**A held gesture reads the items still standing.** The callback runs after the frame that gathered it, and +a rebuild in between can take away the row a hover, a click or a drop landed on. A callback reads what that +item carries through `dpg_get_item_user_data` (`utils/gui/dpg.py`), which answers nothing for an item that +is gone, so a gesture on a row the list has dropped says and does nothing. + ## A gesture that waits keeps the frames going A callback on the render thread holds the frames up for as long as it runs, and a native dialog runs for as diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 4dbadfb3e..f49d9e9bd 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -190,10 +190,6 @@ currently out of line. An entry leaves when the code meets the contract again. * 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/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 - 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 diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py index 03369738f..0b84ddaa0 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -13,7 +13,11 @@ from sampletones_application.ui.elements.stems.host import StemsListHost from sampletones_application.ui.elements.stems.messages import StemsMessages from sampletones_application.ui.elements.stems.tags import StemsTags -from sampletones_application.utils.gui.dpg import dpg_delete_item, dpg_set_value +from sampletones_application.utils.gui.dpg import ( + dpg_delete_item, + dpg_get_item_user_data, + dpg_set_value, +) from sampletones_application.view_model.shared.stems import StemsListViewModel from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.application import Sender @@ -164,13 +168,13 @@ def on_name_selected(self, _sender: Sender, _value: bool, user_data: str) -> Non def on_row_drop(self, sender: Sender, app_data: str) -> None: """A recording was dropped on a row, so it joins that row's level at its place.""" - target = dpg.get_item_user_data(sender) + target = dpg_get_item_user_data(sender) if isinstance(target, str): self._report(self.on_dropped_on_row, app_data, target) def on_level_drop(self, sender: Sender, app_data: str) -> None: """A recording was dropped in a gap, so it takes a level of its own there.""" - position = dpg.get_item_user_data(sender) + position = dpg_get_item_user_data(sender) if isinstance(position, int): self._report(self.on_dropped_on_level, app_data, position) @@ -229,10 +233,10 @@ def _named_by(app_data: Tuple[int, int], button: int) -> Optional[str]: may have taken the widget away, so the answer is for the widgets still standing. """ mouse_button, clicked_item = app_data - if mouse_button != button or not dpg.does_item_exist(clicked_item): + if mouse_button != button: return None - key = dpg.get_item_user_data(clicked_item) + key = dpg_get_item_user_data(clicked_item) return key if isinstance(key, str) else None def _hover_callback(self, message_function: MessageCallback) -> Callable[[Sender, int], None]: @@ -245,10 +249,9 @@ def _hover_callback(self, message_function: MessageCallback) -> Callable[[Sender """ def hover_callback(_sender: Sender, app_data: int) -> None: - if not dpg.does_item_exist(app_data): - return - - self._status_bar.set(message_function, user_data=dpg.get_item_user_data(app_data)) + user_data = dpg_get_item_user_data(app_data) + if user_data is not None: + self._status_bar.set(message_function, user_data=user_data) return hover_callback diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 4c135937e..9331f8dea 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -70,6 +70,7 @@ from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.gui.dpg import ( dpg_configure_item, + dpg_get_item_user_data, dpg_get_value, dpg_is_item_hovered, dpg_set_value, @@ -504,7 +505,10 @@ def hover_callback( _sender: Sender, app_data: int, ) -> None: - user_data = dpg.get_item_user_data(app_data) + user_data = dpg_get_item_user_data(app_data) + if user_data is None: + return + if status_bar_callback is not None: self._status_bar.set(status_bar_callback, user_data=user_data) self._update_detail_tooltip(user_data) @@ -574,10 +578,7 @@ def refresh_detail_tooltip(self) -> None: @staticmethod def _node_at(node_tag: str) -> Optional[TreeNode]: """The node a row was created for, read back from the widget the row is.""" - if not dpg.does_item_exist(node_tag): - return None - - user_data = dpg.get_item_user_data(node_tag) + user_data = dpg_get_item_user_data(node_tag) if not isinstance(user_data, tuple): return None @@ -612,7 +613,10 @@ def single_click_callback( sender: Sender, app_data: Tuple[int, int], ) -> None: - user_data = dpg.get_item_user_data(app_data[1]) + user_data = dpg_get_item_user_data(app_data[1]) + if user_data is None: + return + self._remember_clicked_row(user_data) if item_click_callback is not None: item_click_callback(sender, app_data, user_data=user_data) @@ -630,7 +634,10 @@ def double_click_callback( sender: Sender, app_data: Tuple[int, int], ) -> None: - user_data = dpg.get_item_user_data(app_data[1]) + user_data = dpg_get_item_user_data(app_data[1]) + if user_data is None: + return + if item_double_click_callback is not None: item_double_click_callback( sender, diff --git a/src/sampletones_application/ui/panels/main/source/rows.py b/src/sampletones_application/ui/panels/main/source/rows.py index 5e8a3042a..741656d7e 100644 --- a/src/sampletones_application/ui/panels/main/source/rows.py +++ b/src/sampletones_application/ui/panels/main/source/rows.py @@ -23,7 +23,11 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS, PARTIAL_CHANNEL_THEME_TAGS from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value +from sampletones_application.utils.gui.dpg import ( + dpg_configure_item, + dpg_get_item_user_data, + dpg_set_value, +) from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.view_model.main.source import ChannelSettingsViewModel, SourceSettingsPanelViewModel from sampletones_application.view_model.shared.agreement import Agreement @@ -221,6 +225,9 @@ def _on_drive_released(self, _sender: Sender, slider: Sender) -> None: The slider's clamp holds a typed value within the bounds a drive has, and rounding sets aside the single-precision noise the widget carries its value in. """ - channel_name: ChannelName = dpg.get_item_user_data(slider) + channel_name = dpg_get_item_user_data(slider) + if not isinstance(channel_name, ChannelName): + return + drive = round(float(dpg.get_value(slider)), self._layout.drive_decimals) self.call(self.on_drive_changed, channel_name, drive) diff --git a/src/sampletones_application/ui/panels/sequencer/order/panel.py b/src/sampletones_application/ui/panels/sequencer/order/panel.py index 70050ffad..4996e37ca 100644 --- a/src/sampletones_application/ui/panels/sequencer/order/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/order/panel.py @@ -82,7 +82,11 @@ create_selectable_text_theme, ) from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item +from sampletones_application.utils.gui.dpg import ( + dpg_configure_item, + dpg_delete_item, + dpg_get_item_user_data, +) from sampletones_application.utils.gui.keyboard import ( PRIORITY_PANEL, ActivePredicate, @@ -1000,7 +1004,7 @@ def _on_cell_right_clicked( if mouse_button != dpg.mvMouseButton_Right: return - key = dpg.get_item_user_data(clicked_item) + key = dpg_get_item_user_data(clicked_item) if key is None: return diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py index 4318fbb7d..df743f645 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py @@ -100,7 +100,10 @@ from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu from sampletones_application.ui.panels.sequencer.tracker.themes import TrackerThemes from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_delete_children +from sampletones_application.utils.gui.dpg import ( + dpg_delete_children, + dpg_get_item_user_data, +) from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_application.utils.gui.keyboard import ( PRIORITY_PANEL, @@ -1361,7 +1364,7 @@ def _on_cell_right_clicked( if mouse_button != dpg.mvMouseButton_Right: return - key = dpg.get_item_user_data(clicked_item) + key = dpg_get_item_user_data(clicked_item) if key is None: return @@ -1843,10 +1846,7 @@ def _on_row_number_clicked( ) def _on_row_hovered(self, _sender: Sender, app_data: int) -> None: - if not dpg.does_item_exist(app_data): - return - - row_index = dpg.get_item_user_data(app_data) + row_index = dpg_get_item_user_data(app_data) if row_index is not None: self._highlighted_row = row_index diff --git a/src/sampletones_application/ui/panels/sequencer/voices/panel.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py index 0c7933f74..baa5b95ed 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -32,6 +32,7 @@ from sampletones_application.utils.gui.dpg import ( dpg_configure_item, dpg_delete_children, + dpg_get_item_user_data, dpg_pointer_within_window, ) from sampletones_application.utils.gui.frame import FrameCallbackManager @@ -152,7 +153,7 @@ def _on_row_hovered(self, _sender: Sender, app_data: int) -> None: 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. """ - user_data = dpg.get_item_user_data(app_data) + user_data = dpg_get_item_user_data(app_data) if not isinstance(user_data, tuple): return @@ -600,7 +601,7 @@ def _on_voice_double_clicked( app_data: List[int], ) -> None: clicked_item = app_data[1] - user_data = dpg.get_item_user_data(clicked_item) + user_data = dpg_get_item_user_data(clicked_item) if user_data is not None: _, voice_id = user_data self.call(self.on_voice_edit_requested, voice_id) @@ -614,7 +615,7 @@ def _on_voice_clicked( if mouse_button != dpg.mvMouseButton_Right: return - user_data = dpg.get_item_user_data(clicked_item) + user_data = dpg_get_item_user_data(clicked_item) if user_data is None: return diff --git a/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py index 890be7d1a..50afc54c6 100644 --- a/src/sampletones_application/utils/gui/dpg.py +++ b/src/sampletones_application/utils/gui/dpg.py @@ -140,6 +140,17 @@ def dpg_configure_item(tag: Sender, /, *args: Any, **kwargs: Any) -> None: dpg.configure_item(tag, *args, **kwargs) +@dpg_wrapper() +def dpg_get_item_user_data(tag: Sender, /) -> Any: + """The user data an item carries, or ``None`` once the item is gone. + + A widget's callback runs a frame after DearPyGui gathered it, by which time a rebuild may have + taken the item it names away. A callback reading what a hover, a click or a drop landed on + therefore reads the items still standing, and a gesture on a row that is gone says nothing. + """ + return dpg.get_item_user_data(tag) + + @dpg_wrapper(button_function=GUIButton.set_item_callback) def dpg_set_item_callback( tag: Sender, diff --git a/tests/screens/interface/keybindings/test_rebinding_undo.py b/tests/screens/interface/keybindings/test_rebinding_undo.py index 5925d0527..402841070 100644 --- a/tests/screens/interface/keybindings/test_rebinding_undo.py +++ b/tests/screens/interface/keybindings/test_rebinding_undo.py @@ -14,7 +14,6 @@ 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.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 @@ -124,7 +123,6 @@ def keys_another_action_holds_ask_first(screen: Screen) -> None: 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() diff --git a/tests/screens/sequencer/colors/test_kind_colors.py b/tests/screens/sequencer/colors/test_kind_colors.py index 30a31079b..bb1ce02aa 100644 --- a/tests/screens/sequencer/colors/test_kind_colors.py +++ b/tests/screens/sequencer/colors/test_kind_colors.py @@ -12,7 +12,7 @@ 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.steps.sequencer import leave_letting_the_project_go from tests.suite.screens.views.history import HistoryLine from tests.suite.screens.worlds.songs import BASS_ROW, BASS_VOICE, LINE, PAD, PAD_ROW @@ -193,7 +193,6 @@ def removed_its_older_lines_keep_its_color(screen: Screen) -> None: 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, diff --git a/tests/screens/sequencer/history/test_voice_gestures.py b/tests/screens/sequencer/history/test_voice_gestures.py index 33e607275..70e43d5ea 100644 --- a/tests/screens/sequencer/history/test_voice_gestures.py +++ b/tests/screens/sequencer/history/test_voice_gestures.py @@ -7,7 +7,6 @@ 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 from tests.suite.screens.views.history import HistoryLine from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, BASS_VOICE, LINE, PAD @@ -84,7 +83,6 @@ def step(screen: Screen) -> None: def leave_as_opened(screen: Screen) -> None: """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) screen.press_shortcut(ShortcutId.EXIT) @@ -123,7 +121,6 @@ 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") - 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/voices/test_voices_card.py b/tests/screens/sequencer/voices/test_voices_card.py index 0852f2b7f..5dd5ba23a 100644 --- a/tests/screens/sequencer/voices/test_voices_card.py +++ b/tests/screens/sequencer/voices/test_voices_card.py @@ -16,7 +16,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.steps.sequencer import 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.worlds.recordings import SHORT_RECONSTRUCTION @@ -155,7 +155,6 @@ def test_the_status_names_the_sample(self, screen: Screen) -> None: 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: diff --git a/tests/suite/screens/steps/exports.py b/tests/suite/screens/steps/exports.py index 6e7e9cf8c..d8e9f386a 100644 --- a/tests/suite/screens/steps/exports.py +++ b/tests/suite/screens/steps/exports.py @@ -1,11 +1,9 @@ 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/sequencer.py b/tests/suite/screens/steps/sequencer.py index 328249e76..ce6363885 100644 --- a/tests/suite/screens/steps/sequencer.py +++ b/tests/suite/screens/steps/sequencer.py @@ -7,7 +7,6 @@ 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: @@ -34,16 +33,8 @@ def open_voice(screen: Screen, name: str) -> None: 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") 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..2b61fc131 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,7 @@ -from typing import Final, Optional, Tuple +from typing import Final, Iterator, Optional, Tuple +from unittest.mock import MagicMock +import dearpygui.dearpygui as dpg import pytest from sampletones_application.categories.manager import LanguageManager @@ -92,3 +94,56 @@ def test_a_voice_with_nothing_to_state_says_nothing( footprint: Optional[VoiceFootprintViewModel], ) -> None: assert _panel(footprint)._voice_status_message(voice_id) == "" + + +@pytest.fixture +def dpg_context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +@pytest.fixture +def hovered_panel() -> GUISequencerVoicesPanel: + """The panel over the facts a hovered row reads, saying what it says to a recorded status bar.""" + panel = _panel(SAMPLE_FOOTPRINT) + panel._status_bar = MagicMock() + return panel + + +def _row(voice_id: str) -> int: + """A row of the list, carrying the position and the voice the list gives each row.""" + with dpg.window(): + return int(dpg.add_selectable(label=voice_id, user_data=(0, voice_id))) + + +class TestAHoverReachingTheList: + """A hover is answered a frame after it happened, and the list rebuilds every row on each update.""" + + def test_a_hover_on_a_standing_row_says_what_its_voice_is( + self, + dpg_context: None, + hovered_panel: GUISequencerVoicesPanel, + ) -> None: + row = _row(SAMPLE_ID) + + hovered_panel._on_row_hovered(row, row) + + hovered_panel._status_bar.set.assert_called_once_with(hovered_panel._voice_status_message(SAMPLE_ID)) + + def test_a_hover_reaching_a_row_that_is_gone_says_nothing( + self, + dpg_context: None, + hovered_panel: GUISequencerVoicesPanel, + ) -> None: + gone = _row(SAMPLE_ID) + dpg.delete_item(gone) + + hovered_panel._on_row_hovered(gone, gone) + + hovered_panel._status_bar.set.assert_not_called() + standing = _row(SAMPLE_ID) + hovered_panel._on_row_hovered(standing, standing) + hovered_panel._status_bar.set.assert_called_once() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py index 86f8817b9..421460d7a 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py @@ -766,7 +766,7 @@ def test_a_row_claiming_the_press_leaves_the_list_menu_unbuilt( _deferred_calls(monkeypatch) monkeypatch.setattr(fixture.panel, "_pointer_within_list", lambda: True) monkeypatch.setattr(fixture.panel, "_show_context_menu", lambda _position, _voice_id: None) - monkeypatch.setattr(panel_module.dpg, "get_item_user_data", lambda _item: (SELECTED_ROW, SELECTED_ID)) + monkeypatch.setattr(panel_module, "dpg_get_item_user_data", lambda _item: (SELECTED_ROW, SELECTED_ID)) fixture.panel._on_list_right_clicked(0, RIGHT_BUTTON) fixture.panel._on_voice_clicked(0, (RIGHT_BUTTON, 0)) diff --git a/tests/unit/sampletones_application/utils/gui/test_dpg.py b/tests/unit/sampletones_application/utils/gui/test_dpg.py index 37667c72c..7368ef153 100644 --- a/tests/unit/sampletones_application/utils/gui/test_dpg.py +++ b/tests/unit/sampletones_application/utils/gui/test_dpg.py @@ -3,10 +3,11 @@ import dearpygui.dearpygui as dpg import pytest -from sampletones_application.utils.gui.dpg import dpg_get_item_parent +from sampletones_application.utils.gui.dpg import dpg_get_item_parent, dpg_get_item_user_data ROOT_TAG = "test_root" CHILD_TAG = "test_child" +ROW_DATA = (3, "voice-id") @pytest.fixture @@ -40,3 +41,29 @@ def test_the_library_raises_the_base_class_for_an_absent_item(self, dpg_context: dpg.get_item_parent("never_built") assert type(raised.value) is Exception # pylint: disable=unidiomatic-typecheck + + +class TestTheUserDataOfAnItemAGestureNamed: + """A held callback reads what a gesture landed on a frame late, so a row a rebuild took away reads as nothing.""" + + def test_a_standing_item_answers_with_its_user_data(self, dpg_context: None) -> None: + with dpg.window(tag=ROOT_TAG): + dpg.add_selectable(label="row", tag=CHILD_TAG, user_data=ROW_DATA) + + assert dpg_get_item_user_data(CHILD_TAG) == ROW_DATA + + def test_an_item_a_rebuild_took_away_answers_with_nothing(self, dpg_context: None) -> None: + with dpg.window(tag=ROOT_TAG): + row = dpg.add_selectable(label="row", user_data=ROW_DATA) + dpg.delete_item(row) + + assert dpg_get_item_user_data(row) is None + + def test_the_library_raises_for_an_item_a_rebuild_took_away(self, dpg_context: None) -> None: + """What the guard answers: the bare read of a deleted item raises, and the callback logs it.""" + with dpg.window(tag=ROOT_TAG): + row = dpg.add_selectable(label="row", user_data=ROW_DATA) + dpg.delete_item(row) + + with pytest.raises(Exception, match="Item not found"): + dpg.get_item_user_data(row) From 90a6e955f3e3b8612f1a4890e83d8ec98d4e2b82 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 19:32:27 +0200 Subject: [PATCH 20/84] Fixed: a folder read outliving the exit and a folder asked for during a stop's wind-down --- docs/development/application/render-thread.md | 10 +- docs/development/bugs-and-todos.md | 12 - .../coordinators/tabs/main.py | 37 +- .../logic/main/sources/scan.py | 208 ++++++---- .../services/folder_scan/__init__.py | 21 + .../services/folder_scan/result.py | 64 +++ .../services/folder_scan/service.py | 100 +++++ src/sampletones_application/shell.py | 2 + .../utils/gui/render_thread.py | 50 ++- .../boundaries/general.yaml | 1 + tests/conftest.py | 13 + .../application/closing/test_during_work.py | 5 - .../main/scan/test_reading_a_folder.py | 5 - tests/suite/application.py | 13 + tests/suite/screens/holds/scan.py | 2 +- .../coordinators/tabs/test_main.py | 21 +- .../logic/main/sources/test_scan.py | 372 +++++++++++------- .../services/test_folder_scan.py | 208 ++++++++++ .../sampletones_application/test_startup.py | 9 +- .../utils/gui/test_render_thread.py | 65 ++- 20 files changed, 935 insertions(+), 283 deletions(-) create mode 100644 src/sampletones_application/services/folder_scan/__init__.py create mode 100644 src/sampletones_application/services/folder_scan/result.py create mode 100644 src/sampletones_application/services/folder_scan/service.py create mode 100644 tests/unit/sampletones_application/services/test_folder_scan.py diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index c8e3896b7..e379a4b05 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -33,8 +33,14 @@ Python thread state, which crashes the process. where it stands, and work arriving from any other thread joins the queue. A worker that reads a value or sets one on a standing widget still goes through it, since the hazard is the thread and not the gesture. -A run claims the drawing thread when its loop starts and lets it go when the loop stops. Where no run has -claimed the thread, as while the interface is being built, the work runs in place. +A run claims the drawing thread when its loop starts. Where no run has claimed the thread, as while the +interface is being built, the work runs in place. + +The thread that drew keeps the context after the loop stops, until the context is destroyed, since the +teardown runs there. A worker's work that arrives after the loop has stopped joins the queue like any +other, and the teardown stops the queue, so that work is let go of. The context being taken down therefore +stays with the thread taking it down. A worker reading something long, such as a folder, also listens for +the shutdown and gives up at its next step, so the teardown finds it ended. ## A widget's own gesture is held for the frame diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 08922d013..f2df6d8b6 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -138,10 +138,6 @@ currently out of line. An entry leaves when the code meets the contract again. * `ConverterMessages` reads the strings it shows a reader once, at construction, where principle 8 has text resolve at the point of use. The stage names and status lines are cached as fields, and the run's templates are read live. The fix is to read each key where it is used and let the manager answer. -* `FolderScan` runs a long directory read on a worker and reports back, which is work that `services/` exists for, - while it stands in `logic/`. It reports through optional hooks and not the result union, and the - coordinator crosses to the render thread on its behalf. Moving it would buy the exhaustive `match` every - other long operation reports through. * Every gesture in the converter re-derives the whole setup. A gesture hands `ConverterLogic._rewrite` a state whose recordings are new objects, so the rows and the batch entries are read from it cold, and the garbage collector's own share falls inside them. On a very large folder that is long enough to feel as a @@ -162,10 +158,6 @@ currently out of line. An entry leaves when the code meets the contract again. 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/old_files/test_broken_projects.py` reproduce it as known failures. -* 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/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/row_settings/test_boxes.py` reproduces it as a known failure. @@ -213,7 +205,3 @@ 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/progress/test_refusals.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/closing/test_during_work.py` reproduces it as a - known failure. diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index cdba6564d..03f6eedfb 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -19,6 +19,7 @@ from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.main import MainTabParameters from sampletones_application.services.conversion.service import ConversionService +from sampletones_application.services.folder_scan.service import FolderScanService from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_PANEL_CENTER, @@ -170,7 +171,7 @@ def _build_explorer( open_directories=session_manager.expanded_directories, ) self._file_playback: FilePlayback = FilePlayback(audio_device_manager) - self._folder_scan: FolderScan = FolderScan() + self._folder_scan: FolderScan = FolderScan(FolderScanService(priority=self._repaint_priority)) self._explorer_tree_logic: TreeLogic = TreeLogic( session_manager, self._file_playback, @@ -185,9 +186,6 @@ def _build_explorer( colors=layout.tree_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_EXPLORER_PANEL), ) - self._folder_scan.on_started = self._on_scan_started - self._folder_scan.on_progress = self._on_scan_progress - self._folder_scan.on_stopped = self._on_scan_stopped self._explorer_tree_logic.on_lock_state_changed = self._explorer_panel.set_tree_enabled self._explorer_tree_logic.on_favorite_changed = self._repaint_explorer_favorites self._explorer_tree_logic.on_search_update_needed = self._explorer_panel.update_tree_visibility @@ -257,6 +255,10 @@ def _build_cards( language_manager=language_manager, ) self._scan_window.on_stop = self._folder_scan.stop + self._folder_scan.on_started = self._scan_window.open + self._folder_scan.on_progress = self._scan_window.report + self._folder_scan.on_stopped = self._scan_window.close + self._folder_scan.on_failed = self._on_scan_failed self._converter_panel: GUIConverterPanel = GUIConverterPanel( layout=layout.main.converter, stems_layout=layout.stems, @@ -514,27 +516,15 @@ def _on_directory_add_requested(self, directory_path: Path) -> None: if self._hooks.is_operation_active(): return - self._folder_scan.start(directory_path, self._gather_folder_read) + self._folder_scan.start(directory_path, self._gather_read) - def _on_scan_started(self, directory_path: Path) -> None: - """Puts the wait on screen, since reading a folder of thousands takes seconds.""" - on_render_thread(self._scan_window.open, directory_path, priority=self._repaint_priority) - - def _on_scan_progress(self, count: int) -> None: - on_render_thread(self._scan_window.report, count, priority=self._repaint_priority) - - def _on_scan_stopped(self) -> None: - on_render_thread(self._scan_window.close, priority=self._repaint_priority) - - def _gather_folder_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: - """Gathers what the walk found, on the thread the widgets it draws belong to.""" - on_render_thread(self._gather_read, directory_path, found, priority=self._repaint_priority) - - def _convert_folder_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: - """Converts what the walk found, on the thread the widgets it draws belong to.""" - on_render_thread(self._convert_read, directory_path, found, priority=self._repaint_priority) + def _on_scan_failed(self, exception: Exception) -> None: + """Takes the wait away and says why the folder could not be read.""" + self._scan_window.close() + self._dialogs.show_error(exception) def _gather_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: + """Gathers what the walk found, saying so where the folder holds no recordings.""" self._scan_window.close() if not found: self._nothing_below(directory_path) @@ -559,6 +549,7 @@ def _nothing_below(self, directory_path: Path) -> None: ) def _convert_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: + """Converts what the walk found.""" self._scan_window.close() self._converter_logic.convert_folder(directory_path, found) @@ -788,7 +779,7 @@ def convert_path(self, path: Path) -> None: self._converter_logic.convert_recording(path) return - self._folder_scan.start(path, self._convert_folder_read) + self._folder_scan.start(path, self._convert_read) def save_browser_shape(self) -> None: """Writes down the folders the explorer stands open, so a later run reads down to them.""" diff --git a/src/sampletones_application/logic/main/sources/scan.py b/src/sampletones_application/logic/main/sources/scan.py index 33c12398a..d1b90158e 100644 --- a/src/sampletones_application/logic/main/sources/scan.py +++ b/src/sampletones_application/logic/main/sources/scan.py @@ -1,100 +1,174 @@ -import threading +from dataclasses import dataclass +from enum import Enum, auto from pathlib import Path -from typing import Callable, Final, List, Optional, Tuple - -from sampletones_application.utils.parallelization.thread import concurrent -from sampletones_core.reconstructions.converter.paths import walk_entries -from sampletones_shared.paths.extensions import is_audio_file +from typing import Callable, Optional, Protocol, Tuple + +from sampletones_application.services.folder_scan.result import ( + FolderScanCanceled, + FolderScanError, + FolderScanProgress, + FolderScanRequest, + FolderScanResult, + FolderScanStarted, + FolderScanSuccess, +) from sampletones_shared.types.callback import PathCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin CountCallback = Callable[[int], None] +FailureCallback = Callable[[Exception], None] FoundCallback = Callable[[Path, Tuple[Path, ...]], None] -REPORT_EVERY: Final[int] = 64 -REPORT_DUE: Final[int] = 0 + +class FolderScanServiceProtocol(Protocol): + """The calls a folder scan makes of the service that walks the tree.""" + + def subscribe(self, handler: Callable[[FolderScanResult], None]) -> None: ... + + def start(self, request: FolderScanRequest) -> None: ... + + def stop(self) -> None: ... + + +class ScanPhase(Enum): + """Where the reading of a folder stands, from the reader's request until the walk lets it go.""" + + IDLE = auto() + READING = auto() + WINDING_DOWN = auto() + + +@dataclass(frozen=True) +class AskedFolder: + """A folder asked to be read, and what hears its recordings once it is.""" + + root: Path + answer: FoundCallback class FolderScan(CallbackMixin): - """The recordings below a folder, read beside the interface rather than in front of it. + """The reading of a folder a reader points at, from the moment it is asked for until it is let go. - A folder a reader points at holds a handful of recordings or a disk's worth, and finding out - costs what the tree costs — seconds where the tree is large. The walk therefore runs on a - worker, reports how many it has met as it goes, and stops when the reader asks it to, so the - window keeps answering and the reader knows what it is waiting for. + A reading is idle, reading, or winding down. Stop ends the reading for the reader at once, while + the walk runs on to the next entry it meets, so the scan winds down until the walk is heard to + give up. A folder asked for in that span is read as soon as it has, the latest one asked for + taking the place of an earlier one. A folder asked for while another is being read is turned + away. - The reports arrive on the worker's own thread, so whoever draws from them crosses to the - thread DearPyGui's context belongs to. The scan lives in ``logic/`` because it is short and the - Main tab is its only caller. + The reports arrive on the render thread through the service, each naming the request it + answers, so a report of a reading already let go is set aside. """ - def __init__(self) -> None: - self._stopping = threading.Event() - self._running = threading.Event() + def __init__(self, service: FolderScanServiceProtocol) -> None: + self._service = service + self._phase: ScanPhase = ScanPhase.IDLE + self._reading: Optional[FolderScanRequest] = None + self._answer: Optional[FoundCallback] = None + self._next: Optional[AskedFolder] = None self.on_started: Optional[PathCallback] = None self.on_progress: Optional[CountCallback] = None self.on_stopped: Optional[VoidCallback] = None + self.on_failed: Optional[FailureCallback] = None + + service.subscribe(self._on_result) @property - def running(self) -> bool: - """A walk is under way, which is what the reader is being shown.""" - return self._running.is_set() + def phase(self) -> ScanPhase: + return self._phase def start(self, root: Path, answer: FoundCallback) -> None: """Reads what ``root`` holds and hands it to ``answer``, counting as the walk goes. - The answer travels with the walk that earns it, so the same scan serves a gathering and a - conversion and each hears back from its own reading. One walk runs at a time: a folder - asked for while another is being read is turned away, and asking again once the window - closes reads it. + The answer travels with the reading that earns it, so the same scan serves a gathering and + a conversion and each hears back from its own reading. One reading runs at a time: a folder + asked for while another is being read is turned away, and one asked for once Stop has closed + the window is read as soon as the stopped walk has given up. """ - if self.running: - return - - self._stopping.clear() - self._running.set() - self.call(self.on_started, root) - self._walk(root, answer) + match self._phase: + case ScanPhase.IDLE: + self._begin(AskedFolder(root=root, answer=answer)) + case ScanPhase.READING: + return + case ScanPhase.WINDING_DOWN: + self._next = AskedFolder(root=root, answer=answer) def stop(self) -> None: - """Asks the walk to give up, which it does at the next recording it meets.""" - self._stopping.set() + """Gives the reading up for the reader, which the walk hears at the next entry it meets.""" + if self._phase is not ScanPhase.READING: + return - @concurrent(wait=False) - def _walk(self, root: Path, answer: FoundCallback) -> None: - """Reads the tree, reports how it ended, and lets the walk go, in that order. + self._phase = ScanPhase.WINDING_DOWN + self._service.stop() - The walk holds its claim until its report has gone out, so the worker and the scan agree - on the moment a folder may next be asked for. It is let go whatever becomes of the reading, - so one that fails partway leaves the next folder free to be asked for. - """ - try: - found = self._gather(root) - if self._stopping.is_set(): - self.call(self.on_stopped) - return + def _begin(self, asked: AskedFolder) -> None: + request = FolderScanRequest(root=asked.root) + self._phase = ScanPhase.READING + self._reading = request + self._answer = asked.answer + self._service.start(request) - self.call(answer, root, tuple(sorted(found))) - finally: - self._running.clear() - - def _gather(self, root: Path) -> List[Path]: - """The recordings met below ``root``, giving up at the entry the reader stops the walk on. + def _on_result(self, result: FolderScanResult) -> None: + if result.request is not self._reading: + return - Every entry the tree holds is offered, so a folder of thousands holding a handful of - recordings answers **Stop** as promptly as one holding thousands. + match result: + case FolderScanStarted(request=request): + self._report_started(request.root) + case FolderScanProgress(count=count): + self._report_progress(count) + case FolderScanSuccess(request=request, recordings=recordings): + self._land_found(request.root, recordings) + case FolderScanCanceled(): + self._land_stopped() + case FolderScanError(exception=exception): + self._land_failed(exception) + + def _report_started(self, root: Path) -> None: + if self._phase is ScanPhase.READING: + self.call(self.on_started, root) + + def _report_progress(self, count: int) -> None: + if self._phase is ScanPhase.READING: + self.call(self.on_progress, count) + + def _land_found(self, root: Path, recordings: Tuple[Path, ...]) -> None: + """Hands the recordings to whoever asked, where the reader still waits for them. + + A walk that reached the end of its tree before it heard Stop answers a reader who gave it + up, so it ends the way a stopped one does. """ - found: List[Path] = [] - for path in walk_entries(root): - if self._stopping.is_set(): - return found - - if not is_audio_file(path): - continue - - found.append(path) - if len(found) % REPORT_EVERY == REPORT_DUE: - self.call(self.on_progress, len(found)) + if self._phase is ScanPhase.WINDING_DOWN: + self._land_stopped() + return - return found + answer = self._answer + self._settle() + self.call(answer, root, recordings) + + def _land_stopped(self) -> None: + self._settle() + self.call(self.on_stopped) + self._read_next() + + def _land_failed(self, exception: Exception) -> None: + """Reports a reading that failed partway, and goes on to the folder asked for meanwhile.""" + reading = self._phase is ScanPhase.READING + self._settle() + if reading: + self.call(self.on_failed, exception) + else: + self.call(self.on_stopped) + + self._read_next() + + def _settle(self) -> None: + self._phase = ScanPhase.IDLE + self._reading = None + self._answer = None + + def _read_next(self) -> None: + asked = self._next + self._next = None + if asked is not None: + self._begin(asked) diff --git a/src/sampletones_application/services/folder_scan/__init__.py b/src/sampletones_application/services/folder_scan/__init__.py new file mode 100644 index 000000000..e030e0274 --- /dev/null +++ b/src/sampletones_application/services/folder_scan/__init__.py @@ -0,0 +1,21 @@ +from sampletones_application.services.folder_scan.result import ( + FolderScanCanceled, + FolderScanError, + FolderScanProgress, + FolderScanRequest, + FolderScanResult, + FolderScanStarted, + FolderScanSuccess, +) +from sampletones_application.services.folder_scan.service import FolderScanService + +__all__ = [ + "FolderScanCanceled", + "FolderScanError", + "FolderScanProgress", + "FolderScanRequest", + "FolderScanResult", + "FolderScanService", + "FolderScanStarted", + "FolderScanSuccess", +] diff --git a/src/sampletones_application/services/folder_scan/result.py b/src/sampletones_application/services/folder_scan/result.py new file mode 100644 index 000000000..15c88e8f4 --- /dev/null +++ b/src/sampletones_application/services/folder_scan/result.py @@ -0,0 +1,64 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Tuple, Union + + +@dataclass(frozen=True, eq=False) +class FolderScanRequest: + """One folder a reader asked to have read. + + A request is told apart from every other one by its identity, so a report from a reading the + reader gave up on reads as late, even where the next reading is of the same folder. + + Attributes: + root: The folder whose recordings are read. + """ + + root: Path + + +@dataclass(frozen=True) +class FolderScanStarted: + """The reading of a folder has begun.""" + + request: FolderScanRequest + + +@dataclass(frozen=True) +class FolderScanProgress: + """How many recordings the reading has met so far.""" + + request: FolderScanRequest + count: int + + +@dataclass(frozen=True) +class FolderScanSuccess: + """Every recording below the folder, in name order.""" + + request: FolderScanRequest + recordings: Tuple[Path, ...] + + +@dataclass(frozen=True, eq=False) +class FolderScanError: + """A reading that failed partway, with the failure it raised.""" + + request: FolderScanRequest + exception: Exception + + +@dataclass(frozen=True) +class FolderScanCanceled: + """A reading that gave up at the reader's Stop.""" + + request: FolderScanRequest + + +FolderScanResult = Union[ + FolderScanStarted, + FolderScanProgress, + FolderScanSuccess, + FolderScanError, + FolderScanCanceled, +] diff --git a/src/sampletones_application/services/folder_scan/service.py b/src/sampletones_application/services/folder_scan/service.py new file mode 100644 index 000000000..7e6b45ad0 --- /dev/null +++ b/src/sampletones_application/services/folder_scan/service.py @@ -0,0 +1,100 @@ +import threading +from functools import partial +from pathlib import Path +from typing import Final, List, Optional + +from sampletones_application.services.base import ServiceBase +from sampletones_application.services.folder_scan.result import ( + FolderScanCanceled, + FolderScanError, + FolderScanProgress, + FolderScanRequest, + FolderScanResult, + FolderScanStarted, + FolderScanSuccess, +) +from sampletones_application.utils.parallelization.thread import BackgroundWorkCanceled, SingleThreadExecutor +from sampletones_core.reconstructions.converter.paths import walk_entries +from sampletones_shared.paths.extensions import is_audio_file + +REPORT_EVERY: Final[int] = 64 +REPORT_DUE: Final[int] = 0 + + +class FolderScanService(ServiceBase[FolderScanResult]): + """Reads the recordings below a folder beside the interface, counting them as it goes. + + A folder a reader points at holds a handful of recordings or a disk's worth, and finding out + costs what the tree costs, which is seconds where the tree is large. The walk runs on a worker + of its own and reports how many recordings it has met. It gives up at the next entry it meets + once the reader stops it, and once the application shuts down, so a walk ends with the run. + Every report names the request it answers. + """ + + def __init__(self, priority: int) -> None: + super().__init__(priority) + self._executor = SingleThreadExecutor() + self._stopping = threading.Event() + + def start(self, request: FolderScanRequest) -> None: + """Reads the folder ``request`` names on the worker. + + One reading runs at a time, so a reading started as the last one unwinds waits for it to end. + Each reading listens for a Stop of its own. + """ + stopping = threading.Event() + self._stopping = stopping + self._executor.execute(partial(self._run, request, stopping), wait=True) + + def stop(self) -> None: + """Asks the latest reading to give up, which it does at the next entry it meets.""" + self._stopping.set() + + def _run(self, request: FolderScanRequest, stopping: threading.Event) -> None: + """Reads the folder and reports how the reading ended. + + A shutdown ends the reading with no report, since nothing is left to hear it. + """ + if SingleThreadExecutor.is_shutting_down(): + return + + self._emit(FolderScanStarted(request=request)) + try: + recordings = self._gather(request, stopping) + except BackgroundWorkCanceled: + return + except Exception as exception: # pylint: disable=broad-exception-caught + self._emit(FolderScanError(request=request, exception=exception)) + return + + if recordings is None: + self._emit(FolderScanCanceled(request=request)) + return + + self._emit(FolderScanSuccess(request=request, recordings=tuple(sorted(recordings)))) + + def _gather(self, request: FolderScanRequest, stopping: threading.Event) -> Optional[List[Path]]: + """The recordings met below the folder, or ``None`` where the reader stopped the walk. + + Every entry the tree holds is offered, so a folder of thousands holding a handful of + recordings answers Stop and a shutdown as promptly as one holding thousands. + + Raises: + BackgroundWorkCanceled: If the application shuts down while the walk runs. + """ + found: List[Path] = [] + for path in walk_entries(request.root): + if SingleThreadExecutor.is_shutting_down(): + raise BackgroundWorkCanceled + + if stopping.is_set(): + return None + + if not is_audio_file(path): + continue + + found.append(path) + if len(found) % REPORT_EVERY == REPORT_DUE: + self._emit(FolderScanProgress(request=request, count=len(found))) + + return found diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 3d7de08d9..7abc0fb12 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -40,6 +40,7 @@ from sampletones_application.utils.fps import FPSTimer from sampletones_application.utils.gui.callbacks import hold_callbacks from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.render_thread import reset_render_thread from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, FOLLOW_MODE_SHORTCUT_IDS, @@ -172,6 +173,7 @@ def setup( on_tab_changed: Callback, initial_menu_state: MenuBarViewModel, ) -> None: + reset_render_thread() dpg.create_context() hold_callbacks() self._set_fonts() diff --git a/src/sampletones_application/utils/gui/render_thread.py b/src/sampletones_application/utils/gui/render_thread.py index 5c12531bb..16ca35bbb 100644 --- a/src/sampletones_application/utils/gui/render_thread.py +++ b/src/sampletones_application/utils/gui/render_thread.py @@ -1,6 +1,7 @@ import threading import time from concurrent.futures import ThreadPoolExecutor +from enum import Enum, auto from typing import Any, Callable, Final, Optional, TypeVar import dearpygui.dearpygui as dpg @@ -12,29 +13,59 @@ FRAME_PAUSE: Final[float] = 1 / 60 + +class RenderPhase(Enum): + """Where a run stands with DearPyGui's context, which decides the thread work runs on.""" + + BUILDING = auto() + DRAWING = auto() + STOPPED = auto() + + +_PHASE: RenderPhase = RenderPhase.BUILDING _RENDER_THREAD: Optional[int] = None +def reset_render_thread() -> None: + """Hands a fresh context to whichever thread asks, the state a new interface is built in.""" + global _PHASE, _RENDER_THREAD # pylint: disable=global-statement + _PHASE = RenderPhase.BUILDING + _RENDER_THREAD = None + + def claim_render_thread() -> None: """Names the thread DearPyGui's context belongs to, which is the one drawing the frames.""" - global _RENDER_THREAD # pylint: disable=global-statement + global _PHASE, _RENDER_THREAD # pylint: disable=global-statement + _PHASE = RenderPhase.DRAWING _RENDER_THREAD = threading.get_ident() def release_render_thread() -> None: - """Lets the render thread go, which a run does once its loop has stopped.""" - global _RENDER_THREAD # pylint: disable=global-statement - _RENDER_THREAD = None + """Marks the loop as stopped, which a run does as its teardown begins. + + The thread that ran the loop keeps the context until it is destroyed, since the teardown runs + there. Work from any other thread joins the queue, which the teardown stops, so it never runs. + A run taken down before its loop claimed a thread leaves the context to the thread taking it + down. + """ + global _PHASE, _RENDER_THREAD # pylint: disable=global-statement + _PHASE = RenderPhase.STOPPED + if _RENDER_THREAD is None: + _RENDER_THREAD = threading.get_ident() def is_render_thread() -> bool: """Whether the caller stands where DearPyGui's context is. - A run claims the thread when its loop starts and lets it go when the loop stops, so before and - after that — while the interface is being built, and while it is being taken down — whichever - thread is asking is the one holding the context. + While the interface is being built, no run has claimed the thread, and whichever thread is + asking is the one holding the context. Once a run claims the thread, it holds the context for + as long as the context lasts: while the loop draws, and while the run is taken down after it. """ - return _RENDER_THREAD is None or threading.get_ident() == _RENDER_THREAD + match _PHASE: + case RenderPhase.BUILDING: + return True + case RenderPhase.DRAWING | RenderPhase.STOPPED: + return threading.get_ident() == _RENDER_THREAD def on_render_thread( @@ -48,7 +79,8 @@ def on_render_thread( A worker of our own reaches the interface while the render thread is walking the very items it would create and drop, and an item freed there is freed with no Python thread state — a crash rather than a glitch. Work already on the render thread runs where it stands; work arriving - from any other thread joins the queue the render loop drains, so it lands between frames. + from any other thread joins the queue the render loop drains, so it lands between frames. Once + the loop has stopped, that queue is stopped too, so a worker's late work is let go of. A widget's own callback reaches this as a direct call, since DearPyGui gathers it for the frame to run rather than answering the gesture on a thread of its own. diff --git a/src/sampletones_config/boundaries/general.yaml b/src/sampletones_config/boundaries/general.yaml index e9ff67e9e..be8ddf4d4 100644 --- a/src/sampletones_config/boundaries/general.yaml +++ b/src/sampletones_config/boundaries/general.yaml @@ -8,6 +8,7 @@ groups: - sampletones_application.services.result - sampletones_application.services.conversion.result - sampletones_application.services.export.result + - sampletones_application.services.folder_scan.result - sampletones_application.services.regeneration.result - sampletones_application.services.render.result - sampletones_application.services.retune.result diff --git a/tests/conftest.py b/tests/conftest.py index cc6749031..14a310d9c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_application.utils.gui.palette.palette import PaletteBindings +from sampletones_application.utils.gui.render_thread import reset_render_thread from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction from tests.suite.sequencer import sample_reconstruction @@ -23,6 +24,18 @@ def modal_queue() -> Iterator[None]: ModalQueue.clear() +@pytest.fixture(autouse=True) +def render_thread() -> Iterator[None]: + """Gives each test a context no run has claimed. + + The thread a run claims outlives the run, and a test taking an application down leaves it named, + so each test starts where an interface is built and leaves the next one the same. + """ + reset_render_thread() + yield + reset_render_thread() + + @pytest.fixture(autouse=True) def palette_bindings() -> Iterator[None]: """Gives each test an empty palette binding registry. diff --git a/tests/screens/application/closing/test_during_work.py b/tests/screens/application/closing/test_during_work.py index 773cb61ac..fbfd24ccc 100644 --- a/tests/screens/application/closing/test_during_work.py +++ b/tests/screens/application/closing/test_during_work.py @@ -165,11 +165,6 @@ def world(self) -> World: ), ) - @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: """The application stops after the close while the folder read is in progress.""" converter = screen.main.converter diff --git a/tests/screens/main/scan/test_reading_a_folder.py b/tests/screens/main/scan/test_reading_a_folder.py index b3ec61f30..113378988 100644 --- a/tests/screens/main/scan/test_reading_a_folder.py +++ b/tests/screens/main/scan/test_reading_a_folder.py @@ -186,11 +186,6 @@ def scan_hold(self, screen_holds: Holds, monkeypatch: pytest.MonkeyPatch) -> Sca 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: """The small folder, Ctrl-clicked just after Stop, is gathered once the hold is released.""" converter = screen.main.converter diff --git a/tests/suite/application.py b/tests/suite/application.py index cd4979664..7c597e145 100644 --- a/tests/suite/application.py +++ b/tests/suite/application.py @@ -92,6 +92,19 @@ def drain(self) -> None: callback(*args) +def settled(gesture: VoidCallback) -> None: + """Runs ``gesture``, waits for the workers it started, and runs what they reported. + + A worker reports through the queue the render loop drains, so the reports are held while the + workers run and drained once they have ended, in the order they arrived. + """ + queue = HeldQueue() + with patch.object(CallbackQueue, "add", queue.add): + gesture() + SingleThreadExecutor.join_all() + queue.drain() + + @pytest.fixture def held_queue(monkeypatch: pytest.MonkeyPatch) -> HeldQueue: """``CallbackQueue.add`` holding each callback until the case drains the queue.""" diff --git a/tests/suite/screens/holds/scan.py b/tests/suite/screens/holds/scan.py index 2366b844c..2b47b9238 100644 --- a/tests/suite/screens/holds/scan.py +++ b/tests/suite/screens/holds/scan.py @@ -4,7 +4,7 @@ import pytest -import sampletones_application.logic.main.sources.scan as scan_module +import sampletones_application.services.folder_scan.service as scan_module from tests.suite.screens.holds.signal import ReleaseSignal HELD_ENTRY: Final[str] = "held-entry.txt" diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 2e7b4552a..d7142d7ea 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -10,6 +10,7 @@ from sampletones_application.coordinators.tabs.main import MainTabCoordinator from sampletones_application.logic.main.converter.run import ConversionSuccess from sampletones_application.logic.main.sources.scan import FolderScan +from sampletones_application.services.folder_scan.service import FolderScanService from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION from sampletones_application.tags.main import ( TAG_MAIN_CONVERTER_DIALOG_CANCEL, @@ -18,8 +19,8 @@ TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, ) -from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_core.constants.enums import ChannelName +from tests.suite.application import settled from tests.suite.language import FakeLanguageManager CONVERTER_RUNNING_MESSAGE_KEY: Final[str] = "main.explorer.message.converter_running_msg" @@ -235,10 +236,11 @@ def _stems_coordinator( coordinator._stem_selection_window = MagicMock() coordinator._scan_window = MagicMock() coordinator._repaint_priority = 0 - coordinator._folder_scan = FolderScan() - coordinator._folder_scan.on_started = coordinator._on_scan_started - coordinator._folder_scan.on_progress = coordinator._on_scan_progress - coordinator._folder_scan.on_stopped = coordinator._on_scan_stopped + coordinator._folder_scan = FolderScan(FolderScanService(priority=coordinator._repaint_priority)) + coordinator._folder_scan.on_started = coordinator._scan_window.open + coordinator._folder_scan.on_progress = coordinator._scan_window.report + coordinator._folder_scan.on_stopped = coordinator._scan_window.close + coordinator._folder_scan.on_failed = coordinator._on_scan_failed return coordinator @@ -253,9 +255,12 @@ def _folder_of(tmp_path: Path, count: int) -> Path: def _add_folder(coordinator: MainTabCoordinator, root: Path) -> None: - """Asks for the folder and waits for the reading, the way a reader does.""" - coordinator._on_directory_add_requested(root) - SingleThreadExecutor.join_all() + """Asks for the folder and waits for the reading, the way a reader does. + + The walk's reports reach the coordinator through the queue the render loop drains, so the case + drains it once the walk has ended. + """ + settled(lambda: coordinator._on_directory_add_requested(root)) class TestOutputSwitch: diff --git a/tests/unit/sampletones_application/logic/main/sources/test_scan.py b/tests/unit/sampletones_application/logic/main/sources/test_scan.py index 4464866cb..7f2aefeb8 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_scan.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_scan.py @@ -1,214 +1,302 @@ from pathlib import Path -from typing import List, Tuple +from typing import Callable, Final, List, Optional, Tuple import pytest -from sampletones_application.logic.main.sources import scan as scan_module -from sampletones_application.logic.main.sources.scan import REPORT_EVERY, FolderScan -from sampletones_application.utils.parallelization.thread import SingleThreadExecutor -from tests.suite.base import BaseTestSuite +from sampletones_application.logic.main.sources.scan import FolderScan, ScanPhase +from sampletones_application.services.folder_scan.result import ( + FolderScanCanceled, + FolderScanError, + FolderScanProgress, + FolderScanRequest, + FolderScanResult, + FolderScanStarted, + FolderScanSuccess, +) +MANY: Final[Path] = Path("many") +FEW: Final[Path] = Path("few") +OTHER: Final[Path] = Path("other") +COUNT: Final[int] = 64 +FOUND: Final[Tuple[Path, ...]] = (Path("few/a.wav"), Path("few/b.wav")) -@pytest.fixture(name="scan") -def scan_fixture() -> FolderScan: - return FolderScan() +Answered = List[Tuple[Path, Tuple[Path, ...]]] -def tree(root: Path, count: int, *, deep: int = 0) -> Path: - """A folder holding ``count`` recordings, and ``deep`` more in a folder below it.""" - root.mkdir(parents=True, exist_ok=True) - for index in range(count): - (root / f"take_{index:04d}.wav").touch() +class ScanServiceStandIn: + """The walk a scan drives, its reports handed back by the case the way the render loop drains them.""" - if deep: - tree(root / "below", deep) + def __init__(self) -> None: + self.started: List[FolderScanRequest] = [] + self.stops = 0 + self._handler: Optional[Callable[[FolderScanResult], None]] = None - return root + def subscribe(self, handler: Callable[[FolderScanResult], None]) -> None: + self._handler = handler + def start(self, request: FolderScanRequest) -> None: + self.started.append(request) -def read(scan: FolderScan, root: Path) -> List[Tuple[Path, Tuple[Path, ...]]]: - """Reads the folder and waits for the walk, reporting what the answer was handed.""" - answered: List[Tuple[Path, Tuple[Path, ...]]] = [] - scan.start(root, lambda found_root, found: answered.append((found_root, found))) - SingleThreadExecutor.join_all() - return answered + def stop(self) -> None: + self.stops += 1 + @property + def latest(self) -> FolderScanRequest: + return self.started[-1] -class TestWhatAWalkFinds(BaseTestSuite): - """The walk goes as deep as the folder does and hands what it found to whoever asked.""" + def report(self, result: FolderScanResult) -> None: + assert self._handler is not None + self._handler(result) - def test_every_recording_below_the_folder(self, scan: FolderScan, tmp_path: Path) -> None: - root = tree(tmp_path / "takes", 3, deep=2) - answered = read(scan, root) +class Heard: + """What the reader is told: the folder named, the count, the reading given up, and a failure.""" - assert len(answered[0][1]) == 5 + def __init__(self, scan: FolderScan) -> None: + self.started: List[Path] = [] + self.counts: List[int] = [] + self.stopped = 0 + self.failures: List[Exception] = [] + scan.on_started = self.started.append + scan.on_progress = self.counts.append + scan.on_stopped = self._stop + scan.on_failed = self.failures.append - def test_the_folder_it_was_asked_about(self, scan: FolderScan, tmp_path: Path) -> None: - root = tree(tmp_path / "takes", 1) + def _stop(self) -> None: + self.stopped += 1 - answered = read(scan, root) - assert answered[0][0] == root +@pytest.fixture(name="service") +def service_fixture() -> ScanServiceStandIn: + return ScanServiceStandIn() - def test_they_arrive_in_name_order(self, scan: FolderScan, tmp_path: Path) -> None: - root = tree(tmp_path / "takes", 4) - found = read(scan, root)[0][1] +@pytest.fixture(name="scan") +def scan_fixture(service: ScanServiceStandIn) -> FolderScan: + return FolderScan(service) - assert list(found) == sorted(found) - def test_a_folder_holding_none_answers_with_none(self, scan: FolderScan, tmp_path: Path) -> None: - root = tree(tmp_path / "takes", 0) +@pytest.fixture(name="heard") +def heard_fixture(scan: FolderScan) -> Heard: + return Heard(scan) - assert read(scan, root)[0][1] == () +@pytest.fixture(name="answered") +def answered_fixture() -> Answered: + return [] -class TestWhatTheReaderIsTold(BaseTestSuite): - """The reader hears which folder is being read and how far the walk has got.""" - def test_the_folder_is_named_before_the_walk(self, scan: FolderScan, tmp_path: Path) -> None: - named: List[Path] = [] - scan.on_started = named.append - root = tree(tmp_path / "takes", 1) +def answer_into(answered: Answered) -> Callable[[Path, Tuple[Path, ...]], None]: + return lambda root, recordings: answered.append((root, recordings)) - read(scan, root) - assert named == [root] +class TestAReading: + """A folder asked for is read, and the reader is told what the walk reports about it.""" - def test_the_count_rises_while_it_walks(self, scan: FolderScan, tmp_path: Path) -> None: - counted: List[int] = [] - scan.on_progress = counted.append - root = tree(tmp_path / "takes", REPORT_EVERY * 2) + def test_a_folder_asked_for_is_read(self, scan: FolderScan, service: ScanServiceStandIn) -> None: + scan.start(MANY, lambda _root, _found: None) - read(scan, root) + assert [request.root for request in service.started] == [MANY] + assert scan.phase is ScanPhase.READING - assert counted == [REPORT_EVERY, REPORT_EVERY * 2] + def test_the_reader_is_told_the_folder_and_the_count( + self, + scan: FolderScan, + service: ScanServiceStandIn, + heard: Heard, + ) -> None: + scan.start(MANY, lambda _root, _found: None) + service.report(FolderScanStarted(request=service.latest)) + service.report(FolderScanProgress(request=service.latest, count=COUNT)) -class TestGivingUp(BaseTestSuite): - """A reader who asked for the wrong folder stops the walk rather than waiting it out.""" + assert heard.started == [MANY] + assert heard.counts == [COUNT] - def test_a_stopped_walk_answers_nobody(self, scan: FolderScan, tmp_path: Path) -> None: - root = tree(tmp_path / "takes", REPORT_EVERY * 4) - answered: List[Tuple[Path, Tuple[Path, ...]]] = [] - scan.on_progress = lambda _count: scan.stop() + def test_what_was_found_reaches_whoever_asked( + self, + scan: FolderScan, + service: ScanServiceStandIn, + answered: Answered, + ) -> None: + scan.start(FEW, answer_into(answered)) - scan.start(root, lambda found_root, found: answered.append((found_root, found))) - SingleThreadExecutor.join_all() + service.report(FolderScanSuccess(request=service.latest, recordings=FOUND)) - assert answered == [] + assert answered == [(FEW, FOUND)] + assert scan.phase is ScanPhase.IDLE - def test_it_says_that_it_stopped(self, scan: FolderScan, tmp_path: Path) -> None: - root = tree(tmp_path / "takes", REPORT_EVERY * 4) - stopped: List[bool] = [] - scan.on_stopped = lambda: stopped.append(True) - scan.on_progress = lambda _count: scan.stop() + def test_each_reading_answers_the_caller_that_asked_for_it( + self, + scan: FolderScan, + service: ScanServiceStandIn, + ) -> None: + """A gathering and a conversion ask the one scan, so a reading answering the other would convert a + folder nobody asked about.""" + gathered: Answered = [] + converted: Answered = [] + scan.start(MANY, answer_into(gathered)) + service.report(FolderScanSuccess(request=service.latest, recordings=())) - scan.start(root, lambda _root, _found: None) - SingleThreadExecutor.join_all() + scan.start(FEW, answer_into(converted)) + service.report(FolderScanSuccess(request=service.latest, recordings=FOUND)) - assert stopped == [True] + assert gathered == [(MANY, ())] + assert converted == [(FEW, FOUND)] - def test_a_walk_that_ended_leaves_the_next_free_to_start( + def test_a_folder_asked_for_while_one_is_read_is_turned_away( self, scan: FolderScan, - tmp_path: Path, + service: ScanServiceStandIn, + answered: Answered, ) -> None: - root = tree(tmp_path / "takes", 1) - read(scan, root) + scan.start(MANY, lambda _root, _found: None) - assert scan.running is False - assert len(read(scan, root)) == 1 + scan.start(FEW, answer_into(answered)) + service.report(FolderScanSuccess(request=service.latest, recordings=())) + assert [request.root for request in service.started] == [MANY] + assert answered == [] -class TestOneWalkAtATime(BaseTestSuite): - """The answer travels with the walk that earns it, and a walk is let go however it ends.""" - - def test_each_walk_answers_the_caller_that_asked_for_it( + def test_a_failed_reading_is_reported_and_lets_the_next_start( self, scan: FolderScan, - tmp_path: Path, + service: ScanServiceStandIn, + heard: Heard, ) -> None: - """Two callers ask this one scan — a gathering and a conversion — so a walk that reported - to the other one would convert a folder nobody asked about.""" - first = tree(tmp_path / "first", 2) - second = tree(tmp_path / "second", 3) - gathered: List[Tuple[Path, Tuple[Path, ...]]] = [] - converted: List[Tuple[Path, Tuple[Path, ...]]] = [] - - scan.start(first, lambda root, found: gathered.append((root, found))) - SingleThreadExecutor.join_all() - scan.start(second, lambda root, found: converted.append((root, found))) - SingleThreadExecutor.join_all() - - assert [root for root, _ in gathered] == [first] - assert [root for root, _ in converted] == [second] - - def test_a_walk_that_fails_leaves_the_next_free_to_start( + failure = OSError("the tree went away") + scan.start(MANY, lambda _root, _found: None) + + service.report(FolderScanError(request=service.latest, exception=failure)) + scan.start(FEW, lambda _root, _found: None) + + assert heard.failures == [failure] + assert [request.root for request in service.started] == [MANY, FEW] + + +class TestStoppingAReading: + """Stop gives a reading up for the reader at once, while the walk winds down until it is heard to give up. + + A folder asked for in that span is read once the walk has given up, the latest one asked for + taking the place of an earlier one. Reports of the reading let go are set aside. + """ + + @pytest.fixture(name="stopped") + def stopped_fixture(self, scan: FolderScan, service: ScanServiceStandIn, answered: Answered) -> FolderScan: + """A reading of the large folder, stopped and winding down.""" + scan.start(MANY, answer_into(answered)) + service.report(FolderScanStarted(request=service.latest)) + scan.stop() + return scan + + def test_stop_asks_the_walk_to_give_up_once(self, stopped: FolderScan, service: ScanServiceStandIn) -> None: + stopped.stop() + + assert stopped.phase is ScanPhase.WINDING_DOWN + assert service.stops == 1 + + def test_the_count_of_a_stopped_reading_is_set_aside( self, - scan: FolderScan, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, + stopped: FolderScan, + service: ScanServiceStandIn, + heard: Heard, ) -> None: - """A reading that dies partway holds nothing back, so the reader may ask again.""" - root = tree(tmp_path / "takes", 2) + service.report(FolderScanProgress(request=service.latest, count=COUNT)) - def raising(_root: Path) -> List[Path]: - raise OSError("the tree went away") + assert heard.counts == [] - monkeypatch.setattr(scan_module, "walk_entries", raising) - scan.start(root, lambda _root, _found: None) - SingleThreadExecutor.join_all() + def test_the_walk_giving_up_ends_the_reading( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + heard: Heard, + ) -> None: + service.report(FolderScanCanceled(request=service.latest)) + + assert heard.stopped == 1 + assert stopped.phase is ScanPhase.IDLE - assert scan.running is False + def test_a_folder_asked_for_meanwhile_waits_for_the_walk( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + ) -> None: + stopped.start(FEW, lambda _root, _found: None) - monkeypatch.undo() - assert len(read(scan, root)) == 1 + assert [request.root for request in service.started] == [MANY] + assert stopped.phase is ScanPhase.WINDING_DOWN - def test_a_folder_asked_for_while_one_is_read_is_turned_away( + def test_the_folder_asked_for_meanwhile_is_read_once_the_walk_gives_up( self, - scan: FolderScan, - tmp_path: Path, + stopped: FolderScan, + service: ScanServiceStandIn, ) -> None: - """One walk runs at a time, so the second answer hears nothing until it is asked again.""" - root = tree(tmp_path / "takes", 2) - answered: List[Path] = [] - scan._running.set() + stopped.start(FEW, lambda _root, _found: None) - scan.start(root, lambda found_root, _found: answered.append(found_root)) - SingleThreadExecutor.join_all() + service.report(FolderScanCanceled(request=service.latest)) - assert answered == [] + assert [request.root for request in service.started] == [MANY, FEW] + assert stopped.phase is ScanPhase.READING - def test_a_walk_stands_as_running_while_it_hands_its_answer_over( + def test_the_latest_folder_asked_for_is_the_one_read( self, - scan: FolderScan, - tmp_path: Path, + stopped: FolderScan, + service: ScanServiceStandIn, ) -> None: - """The worker outlives its own answer, and a folder asked for in that moment reaches an - executor still holding the last one, so the scan reads as running for as long as it does.""" - root = tree(tmp_path / "takes", 2) - standing: List[bool] = [] + stopped.start(OTHER, lambda _root, _found: None) + stopped.start(FEW, lambda _root, _found: None) - scan.start(root, lambda _root, _found: standing.append(scan.running)) - SingleThreadExecutor.join_all() + service.report(FolderScanCanceled(request=service.latest)) - assert standing == [True] + assert [request.root for request in service.started] == [MANY, FEW] - def test_a_walk_stands_as_running_while_it_says_it_stopped( + def test_the_folder_read_next_answers_its_own_caller( self, - scan: FolderScan, - tmp_path: Path, + stopped: FolderScan, + service: ScanServiceStandIn, + answered: Answered, + ) -> None: + converted: Answered = [] + stopped.start(FEW, answer_into(converted)) + service.report(FolderScanCanceled(request=service.latest)) + + service.report(FolderScanSuccess(request=service.latest, recordings=FOUND)) + + assert answered == [] + assert converted == [(FEW, FOUND)] + + def test_a_walk_finishing_before_it_heard_stop_answers_nobody( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + heard: Heard, + answered: Answered, + ) -> None: + service.report(FolderScanSuccess(request=service.latest, recordings=FOUND)) + + assert answered == [] + assert heard.stopped == 1 + + def test_a_late_report_of_the_reading_let_go_is_set_aside( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + heard: Heard, + answered: Answered, ) -> None: - """A walk the reader gave up on reports the same way its answer does, so both leave the - scan free at the one moment the worker does.""" - root = tree(tmp_path / "takes", REPORT_EVERY * 4) - standing: List[bool] = [] - scan.on_progress = lambda _count: scan.stop() - scan.on_stopped = lambda: standing.append(scan.running) + let_go = service.latest + stopped.start(FEW, lambda _root, _found: None) + service.report(FolderScanCanceled(request=let_go)) + + service.report(FolderScanProgress(request=let_go, count=COUNT)) + service.report(FolderScanSuccess(request=let_go, recordings=FOUND)) + + assert heard.counts == [] + assert answered == [] + assert stopped.phase is ScanPhase.READING - scan.start(root, lambda _root, _found: None) - SingleThreadExecutor.join_all() + def test_stop_with_nothing_read_asks_nothing(self, scan: FolderScan, service: ScanServiceStandIn) -> None: + scan.stop() - assert standing == [True] + assert service.stops == 0 + assert scan.phase is ScanPhase.IDLE diff --git a/tests/unit/sampletones_application/services/test_folder_scan.py b/tests/unit/sampletones_application/services/test_folder_scan.py new file mode 100644 index 000000000..5ece31758 --- /dev/null +++ b/tests/unit/sampletones_application/services/test_folder_scan.py @@ -0,0 +1,208 @@ +from pathlib import Path +from typing import Iterator, List + +import pytest + +from sampletones_application.services.folder_scan import service as service_module +from sampletones_application.services.folder_scan.result import ( + FolderScanCanceled, + FolderScanError, + FolderScanProgress, + FolderScanRequest, + FolderScanResult, + FolderScanStarted, + FolderScanSuccess, +) +from sampletones_application.services.folder_scan.service import REPORT_EVERY, FolderScanService +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor + +PRIORITY = 0 + + +@pytest.fixture(name="service") +def service_fixture() -> FolderScanService: + return FolderScanService(priority=PRIORITY) + + +@pytest.fixture(name="reports") +def reports_fixture(service: FolderScanService) -> List[FolderScanResult]: + """Every report the service makes, in the order it makes them.""" + reports: List[FolderScanResult] = [] + service.subscribe(reports.append) + return reports + + +@pytest.fixture(name="shutdown") +def shutdown_fixture() -> Iterator[None]: + """Leaves the executor live for the next case after one that shuts it down.""" + yield + SingleThreadExecutor.reset_shutdown() + + +def tree(root: Path, count: int, *, deep: int = 0) -> Path: + """A folder holding ``count`` recordings, and ``deep`` more in a folder below it.""" + root.mkdir(parents=True, exist_ok=True) + for index in range(count): + (root / f"take_{index:04d}.wav").touch() + + if deep: + tree(root / "below", deep) + + return root + + +def found(reports: List[FolderScanResult]) -> List[FolderScanSuccess]: + return [report for report in reports if isinstance(report, FolderScanSuccess)] + + +class TestWhatAWalkFinds: + """The walk goes as deep as the folder does and reports what it found against the request.""" + + def test_every_recording_below_the_folder( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + service.start(FolderScanRequest(root=tree(tmp_path / "takes", 3, deep=2))) + + assert len(found(reports)[0].recordings) == 5 + + def test_every_report_names_the_request_it_answers( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + request = FolderScanRequest(root=tree(tmp_path / "takes", REPORT_EVERY)) + + service.start(request) + + assert all(report.request is request for report in reports) + + def test_they_arrive_in_name_order( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + service.start(FolderScanRequest(root=tree(tmp_path / "takes", 4))) + + recordings = found(reports)[0].recordings + assert list(recordings) == sorted(recordings) + + def test_a_folder_holding_none_answers_with_none( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + service.start(FolderScanRequest(root=tree(tmp_path / "takes", 0))) + + assert found(reports)[0].recordings == () + + +class TestWhatTheReaderIsTold: + """The reader hears that the reading began and how far it has got.""" + + def test_the_reading_says_it_began_before_it_counts( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + service.start(FolderScanRequest(root=tree(tmp_path / "takes", 1))) + + assert isinstance(reports[0], FolderScanStarted) + + def test_the_count_rises_while_it_walks( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + service.start(FolderScanRequest(root=tree(tmp_path / "takes", REPORT_EVERY * 2))) + + counts = [report.count for report in reports if isinstance(report, FolderScanProgress)] + assert counts == [REPORT_EVERY, REPORT_EVERY * 2] + + +class TestGivingUp: + """A reader who asked for the wrong folder stops the walk rather than waiting it out.""" + + def test_a_stopped_walk_reports_that_it_gave_up( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + request = FolderScanRequest(root=tree(tmp_path / "takes", REPORT_EVERY * 4)) + service.subscribe(lambda report: service.stop() if isinstance(report, FolderScanProgress) else None) + + service.start(request) + + assert found(reports) == [] + assert reports[-1] == FolderScanCanceled(request=request) + + def test_a_stop_of_one_reading_leaves_the_next_alone( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + root = tree(tmp_path / "takes", 2) + service.stop() + + service.start(FolderScanRequest(root=root)) + + assert len(found(reports)) == 1 + + def test_a_walk_that_fails_reports_its_failure( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + failure = OSError("the tree went away") + + def raising(_root: Path) -> Iterator[Path]: + raise failure + + monkeypatch.setattr(service_module, "walk_entries", raising) + service.start(FolderScanRequest(root=tree(tmp_path / "takes", 2))) + + assert isinstance(reports[-1], FolderScanError) + assert reports[-1].exception is failure + + +class TestShuttingDown: + """A walk ends with the run: a shutdown is heard at the next entry, and nothing reports after it.""" + + def test_a_shutdown_ends_the_walk_with_no_report( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + shutdown: None, + ) -> None: + service.subscribe( + lambda report: SingleThreadExecutor.request_shutdown() if isinstance(report, FolderScanProgress) else None + ) + + service.start(FolderScanRequest(root=tree(tmp_path / "takes", REPORT_EVERY * 4))) + + assert [type(report) for report in reports] == [FolderScanStarted, FolderScanProgress] + + def test_a_walk_asked_for_after_the_shutdown_never_starts( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + shutdown: None, + ) -> None: + SingleThreadExecutor.request_shutdown() + + service.start(FolderScanRequest(root=tree(tmp_path / "takes", 2))) + + assert reports == [] diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index f43085f01..3a5866119 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -80,7 +80,7 @@ from sampletones_shared.paths.extensions import EXT_FILE_PROJECT from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY from tests.conftest import ReconstructionFactory -from tests.suite.application import HeldQueue, held_queue +from tests.suite.application import HeldQueue, held_queue, settled from tests.suite.gestures import DOUBLE_CLICKED, click_row_name REBOUND_UNDO: Final[Dict[str, str]] = {"Undo": "Ctrl+Alt+U"} @@ -1145,8 +1145,7 @@ def _folder(tmp_path: Path, count: int) -> Path: @staticmethod def _ask(app: Application, directory: Path) -> None: """Ctrl-clicks the folder and waits for the reading, the way a reader does.""" - _ctrl_click_folder(app, directory) - SingleThreadExecutor.join_all() + settled(lambda: _ctrl_click_folder(app, directory)) def test_it_asks_rather_than_gathers(self, app: Application, tmp_path: Path) -> None: directory = self._folder(tmp_path, MAX_STEM_SOURCES + 3) @@ -1324,9 +1323,7 @@ def _click(self, app: Application, directory: Path, *, modifiers: FrozenSet[Modi """Clicks a folder's row, with whatever the reader was holding down, and lets it settle.""" panel = app._main_tab._explorer_panel with patch.object(explorer_module, "capture_modifiers", return_value=modifiers): - panel._directory_node_clicked(self._folder(directory), UNBUILT_ROW) - - SingleThreadExecutor.join_all() + settled(lambda: panel._directory_node_clicked(self._folder(directory), UNBUILT_ROW)) def test_a_plain_click_gathers_nothing(self, app: Application, tmp_path: Path) -> None: directory = self._tree(tmp_path) diff --git a/tests/unit/sampletones_application/utils/gui/test_render_thread.py b/tests/unit/sampletones_application/utils/gui/test_render_thread.py index 210847338..3d0980528 100644 --- a/tests/unit/sampletones_application/utils/gui/test_render_thread.py +++ b/tests/unit/sampletones_application/utils/gui/test_render_thread.py @@ -1,5 +1,5 @@ import threading -from typing import List +from typing import Callable, List import pytest @@ -9,16 +9,24 @@ is_render_thread, on_render_thread, release_render_thread, + reset_render_thread, ) @pytest.fixture def unclaimed() -> None: """A context no run has claimed, over a queue live enough to drain what reaches it.""" - release_render_thread() + reset_render_thread() CallbackQueue.start() +def from_another_thread(work: Callable[[], None]) -> None: + """Runs ``work`` on a thread of its own and waits for it, the way a worker reaches the interface.""" + worker = threading.Thread(target=work) + worker.start() + worker.join() + + class TestWhereWorkRuns: """Work that creates or deletes widgets belongs on the thread holding DearPyGui's context.""" @@ -54,8 +62,59 @@ def test_another_thread_joins_the_queue_the_loop_drains(self, unclaimed: None) - assert ran == ["gestured"] - def test_a_run_lets_the_thread_go(self, unclaimed: None) -> None: + +class TestAfterTheLoopStopped: + """Once a run's loop stops, the thread that drew keeps the context until the context is destroyed. + + The teardown runs on that thread, so its work runs where it stands. A worker's late work joins the + queue, which the teardown stops, so it never reaches a context that is going or gone. + """ + + def test_the_thread_that_drew_still_holds_the_context(self, unclaimed: None) -> None: + ran: List[str] = [] + claim_render_thread() + release_render_thread() + + on_render_thread(ran.append, "taken down") + + assert is_render_thread() is True + assert ran == ["taken down"] + + def test_another_thread_holds_no_context(self, unclaimed: None) -> None: + answers: List[bool] = [] + claim_render_thread() + release_render_thread() + + from_another_thread(lambda: answers.append(is_render_thread())) + + assert answers == [False] + + def test_a_late_worker_s_work_is_let_go_with_the_stopped_queue(self, unclaimed: None) -> None: + ran: List[str] = [] claim_render_thread() release_render_thread() + CallbackQueue.stop() + + from_another_thread(lambda: on_render_thread(ran.append, "late")) + CallbackQueue.process(1.0) + + assert ran == [] + + def test_a_run_taken_down_before_it_drew_leaves_the_context_to_its_teardown(self, unclaimed: None) -> None: + answers: List[bool] = [] + + release_render_thread() + from_another_thread(lambda: answers.append(is_render_thread())) assert is_render_thread() is True + assert answers == [False] + + def test_a_fresh_context_is_built_by_whichever_thread_asks(self, unclaimed: None) -> None: + answers: List[bool] = [] + claim_render_thread() + release_render_thread() + + reset_render_thread() + from_another_thread(lambda: answers.append(is_render_thread())) + + assert answers == [True] From 77489334a490970db5e10770cc0a211414f10936 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 19:39:12 +0200 Subject: [PATCH 21/84] Rewrote: the import-with-no-project scenario for the greyed pool entries --- .../exports/instruments/import_files/steps.py | 9 +++- .../instruments/import_files/test_import.py | 47 +++++++++++-------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/tests/screens/exports/instruments/import_files/steps.py b/tests/screens/exports/instruments/import_files/steps.py index cf8b34571..b82f1d962 100644 --- a/tests/screens/exports/instruments/import_files/steps.py +++ b/tests/screens/exports/instruments/import_files/steps.py @@ -20,11 +20,16 @@ def kept_folder() -> Path: return path -def choose_import(screen: Screen) -> None: - """Chooses Import instrument... on the menu the empty foot of the voice list opens.""" +def open_the_list_menu(screen: Screen) -> None: + """Opens the menu the empty foot of the voice list offers.""" 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") + + +def choose_import(screen: Screen) -> None: + """Chooses Import instrument... on the menu the empty foot of the voice list opens.""" + open_the_list_menu(screen) screen.context_menu.choose(screen.words(IMPORT_INSTRUMENT)) diff --git a/tests/screens/exports/instruments/import_files/test_import.py b/tests/screens/exports/instruments/import_files/test_import.py index c4e253618..f25dd6dcc 100644 --- a/tests/screens/exports/instruments/import_files/test_import.py +++ b/tests/screens/exports/instruments/import_files/test_import.py @@ -5,8 +5,13 @@ 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.exports.instruments.import_files.constants import KEPT, VOICES -from tests.screens.exports.instruments.import_files.steps import choose_import, export_kept, segments +from tests.screens.exports.instruments.import_files.constants import IMPORT_INSTRUMENT, KEPT, VOICES +from tests.screens.exports.instruments.import_files.steps import ( + choose_import, + export_kept, + open_the_list_menu, + 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 @@ -15,8 +20,6 @@ from tests.suite.screens.worlds.songs import PAD, RELEASING_INSTRUMENT, releasing_instrument IMPORT_TITLE: Final[str] = "sequencer.voices.title.import_instrument_dialog" -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" @@ -25,20 +28,20 @@ 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 stands greyed out 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. + The project is closed, and the list's menu offers Import instrument... greyed out: a click on it asks for + no file and raises no notice. A new project is created, and the same choice now asks for a file. """ - 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. + def test_it_stands_greyed_out_and_asks_for_no_file(self, screen: Screen) -> None: + """The entry is greyed out 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 + menu = screen.context_menu asked: List[int] = [] def close_the_project(screen: Screen) -> None: @@ -49,15 +52,19 @@ def close_the_project(screen: Screen) -> None: 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) + def import_stands_greyed_out(screen: Screen) -> None: + open_the_list_menu(screen) + entry = next(entry for entry in menu.entries() if entry.label == screen.words(IMPORT_INSTRUMENT)) + assert not entry.enabled - 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) + menu.choose(screen.words(IMPORT_INSTRUMENT)) + + screen.frames(SETTLING_FRAMES) + assert menu.is_shown() + assert not notice.is_shown() assert len(screen.dialog_requests()) == asked[0] - notice.dismiss() - screen.expect(notice.is_shown, operator.not_, description="the notice gone") + menu.dismiss() + screen.expect(menu.is_shown, operator.not_, description="the menu put away") def with_a_new_project_it_asks_for_a_file(screen: Screen) -> None: screen.project.create() @@ -78,7 +85,7 @@ def with_a_new_project_it_asks_for_a_file(screen: Screen) -> None: screen.scenario( close_the_project, - import_says_no_project_is_open, + import_stands_greyed_out, with_a_new_project_it_asks_for_a_file, leaving_asks_nothing, ).run() From 82b83d65179a01c0a4168b607eddbc4e843f2c3b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 19:41:41 +0200 Subject: [PATCH 22/84] Reworded: the lifecycle fixes' docstrings in positive terms --- src/sampletones_application/application.py | 2 +- src/sampletones_application/coordinators/tabs/main.py | 2 +- src/sampletones_application/logic/project/manager.py | 2 +- src/sampletones_application/utils/callbacks/gates.py | 2 +- src/sampletones_application/utils/gui/render_thread.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 45c48912e..8c0b25cc3 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1746,7 +1746,7 @@ def _teardown_steps(self) -> Tuple[VoidCallback, ...]: """The steps of the teardown in the order they are taken. Background work stops before anything it reaches is let go of. Display settings put back what - they never confirmed before the session records the window, the session is written while the + is left unconfirmed before the session records the window, the session is written while the window it measures still stands, and the DearPyGui context goes last. """ return ( diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 03f6eedfb..0cd0d9a43 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -519,7 +519,7 @@ def _on_directory_add_requested(self, directory_path: Path) -> None: self._folder_scan.start(directory_path, self._gather_read) def _on_scan_failed(self, exception: Exception) -> None: - """Takes the wait away and says why the folder could not be read.""" + """Takes the wait away and shows the failure that ended the reading.""" self._scan_window.close() self._dialogs.show_error(exception) diff --git a/src/sampletones_application/logic/project/manager.py b/src/sampletones_application/logic/project/manager.py index 6cd86ced4..8b475c206 100644 --- a/src/sampletones_application/logic/project/manager.py +++ b/src/sampletones_application/logic/project/manager.py @@ -17,7 +17,7 @@ class ProjectManager(CallbackMixin): - Lifecycle events are emitted by its ``session``; callers that need to react to lifecycle transitions subscribe to ``session.on_state_changed``. - The project stands for the file it was last loaded from or saved to, its :attr:`path`. A - project created here and never saved stands for none. ``on_path_changed`` reports each new + project made here stands for a file once it is first saved. ``on_path_changed`` reports each new path, so the session remembers the project the next run reopens. """ diff --git a/src/sampletones_application/utils/callbacks/gates.py b/src/sampletones_application/utils/callbacks/gates.py index 46c992378..9fef9a448 100644 --- a/src/sampletones_application/utils/callbacks/gates.py +++ b/src/sampletones_application/utils/callbacks/gates.py @@ -95,7 +95,7 @@ def __init__( @property def in_flight(self) -> bool: - """Whether a conversation of this gesture has been asked for and has not yet ended.""" + """Whether a conversation of this gesture is under way, from the moment it is asked for to its end.""" return self._in_flight def __call__(self, *args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> None: diff --git a/src/sampletones_application/utils/gui/render_thread.py b/src/sampletones_application/utils/gui/render_thread.py index 16ca35bbb..7c7b14aa0 100644 --- a/src/sampletones_application/utils/gui/render_thread.py +++ b/src/sampletones_application/utils/gui/render_thread.py @@ -44,7 +44,7 @@ def release_render_thread() -> None: """Marks the loop as stopped, which a run does as its teardown begins. The thread that ran the loop keeps the context until it is destroyed, since the teardown runs - there. Work from any other thread joins the queue, which the teardown stops, so it never runs. + there. Work from any other thread joins the queue, which the teardown stops, so the queue lets it go. A run taken down before its loop claimed a thread leaves the context to the thread taking it down. """ From e3938b9c6d6f7c632765f4cdacc9302b8a14f860 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 20:08:37 +0200 Subject: [PATCH 23/84] Steadied: the Play-from-row scenario against the grid a playing song moves --- .../sequencer/tracker/test_notes_typed.py | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/tests/screens/sequencer/tracker/test_notes_typed.py b/tests/screens/sequencer/tracker/test_notes_typed.py index 0a313219e..2dee95265 100644 --- a/tests/screens/sequencer/tracker/test_notes_typed.py +++ b/tests/screens/sequencer/tracker/test_notes_typed.py @@ -200,27 +200,18 @@ class TestPlayingFromATrackerRow: """ def test_the_menu_follows(self, screen: Screen) -> None: - """The play-from-row shortcut and the cell menu's Play from this frame each turn the menu to Pause, and - Stop from the menu and from its key each turn it back to Play. + """The cell menu's Play from this frame and the play-from-row shortcut each turn the menu to Pause, and + the Stop key and Stop on the menu each turn it back to Play. + + Every gesture on a cell comes before the song first plays, since a playing song moves the grid. """ tracker = screen.sequencer.tracker menu = screen.context_menu - def the_shortcut_plays_from_the_row(screen: Screen) -> None: + def the_cell_menu_plays_from_the_frame(screen: Screen) -> None: on_the_sequencer(screen) tracker.click(1, ChannelName.PULSE1, SubColumn.VOICE) reads_as_stopped(screen) - - screen.press_shortcut(ShortcutId.TRACKER_PLAY_FROM_ROW) - - reads_as_playing(screen) - - def stop_from_the_menu_reads_as_stopped(screen: Screen) -> None: - screen.sequencer.playback.stop() - - reads_as_stopped(screen) - - def the_cell_menu_plays_from_the_frame(screen: Screen) -> None: tracker.right_click(1, ChannelName.PULSE1, SubColumn.VOICE) screen.expect(menu.is_shown, bool, description="the cell's menu") @@ -234,9 +225,19 @@ def the_stop_key_reads_as_stopped(screen: Screen) -> None: reads_as_stopped(screen) + def the_shortcut_plays_from_the_row(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.TRACKER_PLAY_FROM_ROW) + + reads_as_playing(screen) + + def stop_from_the_menu_reads_as_stopped(screen: Screen) -> None: + screen.sequencer.playback.stop() + + reads_as_stopped(screen) + screen.scenario( - the_shortcut_plays_from_the_row, - stop_from_the_menu_reads_as_stopped, the_cell_menu_plays_from_the_frame, the_stop_key_reads_as_stopped, + the_shortcut_plays_from_the_row, + stop_from_the_menu_reads_as_stopped, ).run() From ca64df132dbb19383e63925d21810bd928e94d56 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 20:08:48 +0200 Subject: [PATCH 24/84] Fixed: Export instrument in a two-tunings project failing with no message --- docs/development/bugs-and-todos.md | 4 -- src/sampletones_application/application.py | 3 + .../coordinators/export/instrument.py | 23 +++++++- .../logic/export/instrument/logic.py | 2 +- .../logic/export/instrument/source.py | 6 +- src/sampletones_core/project/tuning.py | 7 ++- src/sampletones_shared/exceptions/__init__.py | 2 + src/sampletones_shared/exceptions/project.py | 4 ++ .../screens/exports/progress/test_refusals.py | 15 +++-- tests/suite/screens/views/context_menu.py | 21 +++++-- .../coordinators/export/test_instrument.py | 57 ++++++++++++++++++- .../sampletones_core/project/test_tuning.py | 5 +- 12 files changed, 121 insertions(+), 28 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index f49d9e9bd..0940e195d 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -190,10 +190,6 @@ currently out of line. An entry leaves when the code meets the contract again. * 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/song/test_retuning_and_saving.py` reproduces it as a known failure. -* 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/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. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 8111049ed..bce2b2c6d 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -4,6 +4,7 @@ import dearpygui.dearpygui as dpg from pydantic import ValidationError +from sampletones_application.categories.export import ExportMessages from sampletones_application.categories.hierarchy import Tab from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.deployment.deployment import ( @@ -479,6 +480,8 @@ def __init__( self.export_backends, ), self.language_manager, + dialogs=self.dialogs, + messages=ExportMessages.build(self.language_manager), ) self._reconstructions_tab = ReconstructionTabCoordinator( diff --git a/src/sampletones_application/coordinators/export/instrument.py b/src/sampletones_application/coordinators/export/instrument.py index 4e662c2d4..c0efb6c37 100644 --- a/src/sampletones_application/coordinators/export/instrument.py +++ b/src/sampletones_application/coordinators/export/instrument.py @@ -2,6 +2,7 @@ from typing import Dict, Optional, Tuple from sampletones_application.categories.elements.global_ import FileFilterElements +from sampletones_application.categories.export import ExportMessages from sampletones_application.categories.exports import ( EXPORT_INSTRUMENT_FILTERS, INSTRUMENT_EXPORT_FORMATS, @@ -12,10 +13,13 @@ from sampletones_application.utils.file_dialogs.api import save_file_dialog from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path +from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_core.constants.enums import ChannelName from sampletones_core.exports.format import ExportFormat from sampletones_core.exports.request import InstrumentSource from sampletones_core.exports.scope import ExportScope +from sampletones_shared.exceptions import ProjectTuningError +from sampletones_shared.logger import logger class InstrumentExportCoordinator: @@ -26,14 +30,22 @@ class InstrumentExportCoordinator: whichever of them asked. All three formats that write a single instrument are offered at once, which leaves choosing one to the dialog's own type selector rather than to the menu that reached it. + + A project voice is measured against the project before a file is asked for, so a voice the + project cannot state as one instrument is refused with a message ahead of the save dialog. """ def __init__( self, export_logic: InstrumentExportLogic, language_manager: LanguageManager, + *, + dialogs: DialogsRenderer, + messages: ExportMessages, ) -> None: self._logic = export_logic + self._dialogs = dialogs + self._messages = messages self._title = language_manager["reconstructions.instruments.title.export_instrument_dialog"] self._filter_names: Dict[ExportFormat, str] = { export_format: self._filter_name(language_manager, element) @@ -64,14 +76,21 @@ def request_voice( Both surfaces naming a voice — the sequencer's voice menu and the Reconstructions tab's export button — reach a file this way, so the same voice is written the same bytes - whichever of them asked. + whichever of them asked. A project whose samples were converted at different tunings + states no one tuning for the instrument, so the export stops there with a message. Args: voice_id: The voice the instrument belongs to. channel_name: The channel the instrument is stated for, ``None`` where the voice holds the one set of envelopes every channel reads. """ - exportable = self._logic.voice_instrument(voice_id, channel_name) + try: + exportable = self._logic.voice_instrument(voice_id, channel_name) + except ProjectTuningError as exception: + logger.warning(f"The instrument of voice {voice_id} was refused: {exception}") + self._dialogs.show_error(exception, self._messages.instrument_failed) + return + if exportable is not None: self.request(exportable.source, exportable.name) diff --git a/src/sampletones_application/logic/export/instrument/logic.py b/src/sampletones_application/logic/export/instrument/logic.py index f03624018..8c4dadc4a 100644 --- a/src/sampletones_application/logic/export/instrument/logic.py +++ b/src/sampletones_application/logic/export/instrument/logic.py @@ -90,7 +90,7 @@ def voice_instrument( ``None`` where the voice holds no instrument answering to that channel. Raises: - ValueError: If the project's samples were reconstructed against tunings that differ. + ProjectTuningError: If the project's samples were reconstructed against tunings that differ. """ return voice_instrument(self._controller.project, voice_id, channel_name) diff --git a/src/sampletones_application/logic/export/instrument/source.py b/src/sampletones_application/logic/export/instrument/source.py index 5c710914b..bbafdb234 100644 --- a/src/sampletones_application/logic/export/instrument/source.py +++ b/src/sampletones_application/logic/export/instrument/source.py @@ -82,7 +82,7 @@ def instrument_source( InstrumentSource: The instrument, awaiting the name its destination gives it. Raises: - ValueError: If the project's samples were reconstructed against tunings that differ. + ProjectTuningError: If the project's samples were reconstructed against tunings that differ. """ return InstrumentSource( channel=sounding_channel(entry), @@ -106,7 +106,7 @@ def exportable_instrument( ExportableInstrument: The instrument and the name to suggest for it. Raises: - ValueError: If the project's samples were reconstructed against tunings that differ. + ProjectTuningError: If the project's samples were reconstructed against tunings that differ. """ return ExportableInstrument( name=entry.name, @@ -158,7 +158,7 @@ def voice_instrument( channel. Raises: - ValueError: If the project's samples were reconstructed against tunings that differ. + ProjectTuningError: If the project's samples were reconstructed against tunings that differ. """ voice = project.voices.get(voice_id) if voice is None: diff --git a/src/sampletones_core/project/tuning.py b/src/sampletones_core/project/tuning.py index f961e4723..7afd772ed 100644 --- a/src/sampletones_core/project/tuning.py +++ b/src/sampletones_core/project/tuning.py @@ -2,6 +2,7 @@ from sampletones_core.project.project import Project from sampletones_core.project.voices.voice import samples +from sampletones_shared.exceptions import ProjectTuningError from sampletones_shared.music import Tuning UNTUNED_PROJECT: Final[Tuning] = Tuning() @@ -26,8 +27,8 @@ def tuning_from_project(project: Project) -> Tuning: Tuning: The tuning every sample of the project was reconstructed against. Raises: - ValueError: If the samples were reconstructed against tunings that differ, which one - timer table sounds only one of. + ProjectTuningError: If the samples were reconstructed against tunings that differ, which + one timer table sounds only one of. """ tunings: Set[Tuning] = {sample.reconstruction.config.tuning for sample in samples(project.voices)} if not tunings: @@ -35,6 +36,6 @@ def tuning_from_project(project: Project) -> Tuning: if len(tunings) > 1: stated = ", ".join(sorted(_named(tuning) for tuning in tunings)) - raise ValueError(f"a project sounds one tuning, and its samples were reconstructed at {stated}") + raise ProjectTuningError(f"a project sounds one tuning, and its samples were reconstructed at {stated}") return tunings.pop() diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index 05f0972fe..9a59ad323 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -38,6 +38,7 @@ LoadProjectError, MissingProjectDataFileError, NotAValidArchiveError, + ProjectTuningError, UnhandledProjectError, ) from .reconstruction import ( @@ -98,6 +99,7 @@ "OperationCanceled", "PlaybackError", "PlayerError", + "ProjectTuningError", "ReconstructionError", "SampleToNESError", "SerializationError", diff --git a/src/sampletones_shared/exceptions/project.py b/src/sampletones_shared/exceptions/project.py index 34dd007b5..189a3c230 100644 --- a/src/sampletones_shared/exceptions/project.py +++ b/src/sampletones_shared/exceptions/project.py @@ -33,3 +33,7 @@ class MissingProjectDataFileError(LoadProjectError): class UnhandledProjectError(LoadProjectError): """Raised when an unhandled error is encountered while loading a project.""" + + +class ProjectTuningError(ProjectError, ValueError): + """Raised when a project's samples were converted at tunings that differ, where one tuning is needed.""" diff --git a/tests/screens/exports/progress/test_refusals.py b/tests/screens/exports/progress/test_refusals.py index e11ad4bf7..0e807626f 100644 --- a/tests/screens/exports/progress/test_refusals.py +++ b/tests/screens/exports/progress/test_refusals.py @@ -18,6 +18,7 @@ NSF_FAILED: Final[str] = "global.dialog.message.nsf_project_export_failed" BITPHASE_FAILED: Final[str] = "global.dialog.message.bitphase_project_export_failed" +INSTRUMENT_FAILED: Final[str] = "reconstructions.instruments.message.export_instrument_failed" STORED_TICK_BY_TICK: Final[str] = "settings.nsf.label.scheme_none" EVERY_REPEAT_ONCE: Final[str] = "settings.nsf.label.scheme_search" @@ -110,12 +111,10 @@ def a_module_is_written(screen: Screen) -> None: 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: + """Export instrument... on a sample says the instrument failed and asks for no file.""" + notice = screen.error_notice + asked = len(screen.dialog_requests()) open_voice_menu(screen, LINE) screen.context_menu.choose_in( @@ -123,4 +122,8 @@ def test_an_instrument_export_stops_with_a_message(self, screen: Screen) -> None screen.channel_words(INSTRUMENT_CHANNEL), ) - screen.expect(screen.error_notice.is_shown, bool, description="the export refused with a message") + screen.expect(notice.is_shown, bool, description="the export refused with a message") + assert screen.words(INSTRUMENT_FAILED) in notice.words() + assert len(screen.dialog_requests()) == asked + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the error gone") diff --git a/tests/suite/screens/views/context_menu.py b/tests/suite/screens/views/context_menu.py index 71fefc79b..a20c61b28 100644 --- a/tests/suite/screens/views/context_menu.py +++ b/tests/suite/screens/views/context_menu.py @@ -10,7 +10,7 @@ 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 +from tests.suite.screens.dearpygui.semantic import CallbackRunner, invoke ENTRY_INSET: Final[Point] = Point(x=12, y=8) CLEAR_OF_THE_TABS: Final[int] = 48 @@ -103,8 +103,10 @@ def choose_in( ``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. + nowhere, so the entry is chosen by running its callback the way a click does. A click on an + entry closes the menu in the frame it lands in, and the callback runs once that frame is drawn, + so the menu is put away by a click beside it before the callback runs. A dialog the entry + raises then opens on a screen the menu has left, as it does for a person's click. Raises: MissingEntryError: If the menu offers no such submenu. @@ -112,9 +114,16 @@ def choose_in( 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() + pressed: List[Tuple[CallbackRunner, Tuple[object, ...]]] = [] + self._bridge.ask( + lambda: invoke( + self._submenu_entry(menu_label, entry_label), + lambda callback, *arguments: pressed.append((callback, arguments)), + ) + ) + self.dismiss() + callback, arguments = pressed[0] + self._bridge.ask(lambda: CallbackQueue.run(callback, *arguments)) def choose(self, label: str) -> None: """Clicks the entry reading ``label``. diff --git a/tests/unit/sampletones_application/coordinators/export/test_instrument.py b/tests/unit/sampletones_application/coordinators/export/test_instrument.py index c1b870f9a..70e3ea2a4 100644 --- a/tests/unit/sampletones_application/coordinators/export/test_instrument.py +++ b/tests/unit/sampletones_application/coordinators/export/test_instrument.py @@ -4,6 +4,7 @@ import pytest +from sampletones_application.categories.export import ExportMessages from sampletones_application.categories.exports import INSTRUMENT_EXPORT_FORMATS from sampletones_application.categories.manager import LanguageManager from sampletones_application.coordinators.export import instrument as instrument_module @@ -22,6 +23,7 @@ from sampletones_core.exports.scope import ExportScope from sampletones_core.project.project import Project from sampletones_core.project.voices.creation import new_instrument +from sampletones_shared.exceptions import ProjectTuningError REMEMBERED_DIRECTORY: Final[Path] = Path("/instruments") SUGGESTED_NAME: Final[str] = "Lead" @@ -44,8 +46,19 @@ def logic() -> MagicMock: @pytest.fixture -def coordinator(logic: MagicMock) -> InstrumentExportCoordinator: - return InstrumentExportCoordinator(logic, LanguageManager(LANG_EN)) +def dialogs() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def coordinator(logic: MagicMock, dialogs: MagicMock) -> InstrumentExportCoordinator: + language_manager = LanguageManager(LANG_EN) + return InstrumentExportCoordinator( + logic, + language_manager, + dialogs=dialogs, + messages=ExportMessages.build(language_manager), + ) @pytest.fixture @@ -174,6 +187,46 @@ def test_what_a_voice_offers_is_the_exporters_own_answer( assert coordinator.voice_instruments("bass-id") == (ChannelName.PULSE1, ChannelName.NOISE) +class TestAVoiceTheProjectCannotTune: + """A project whose samples were converted at two tunings states no one tuning for an instrument. + + The export stops before a file is asked for, and says so. + """ + + def test_the_export_stops_with_a_message_before_the_dialog( + self, + coordinator: InstrumentExportCoordinator, + logic: MagicMock, + dialogs: MagicMock, + confirmed: List[Dict[str, object]], + ) -> None: + refusal = ProjectTuningError("a project sounds one tuning") + logic.voice_instrument.side_effect = refusal + + coordinator.request_voice("lead-id", None) + + assert confirmed == [] + logic.export.assert_not_called() + dialogs.show_error.assert_called_once_with( + refusal, + LanguageManager(LANG_EN)["reconstructions.instruments.message.export_instrument_failed"], + ) + + def test_a_voice_the_project_tunes_asks_for_a_file_and_says_nothing( + self, + coordinator: InstrumentExportCoordinator, + logic: MagicMock, + dialogs: MagicMock, + confirmed: List[Dict[str, object]], + ) -> None: + logic.voice_instrument.return_value = ExportableInstrument(name=SUGGESTED_NAME, source=_source()) + + coordinator.request_voice("lead-id", None) + + assert len(confirmed) == 1 + dialogs.show_error.assert_not_called() + + class TestWritingWhatWasConfirmed: def test_the_destination_reaches_the_write( self, diff --git a/tests/unit/sampletones_core/project/test_tuning.py b/tests/unit/sampletones_core/project/test_tuning.py index 1e422331d..626202d36 100644 --- a/tests/unit/sampletones_core/project/test_tuning.py +++ b/tests/unit/sampletones_core/project/test_tuning.py @@ -5,6 +5,7 @@ from sampletones_core.project.project import Project from sampletones_core.project.tuning import UNTUNED_PROJECT, tuning_from_project from sampletones_core.project.voices.sample import Sample +from sampletones_shared.exceptions import ProjectTuningError from sampletones_shared.music import Tuning from tests.suite.performance import ( make_pulse_reconstruction, @@ -59,5 +60,7 @@ def test_samples_that_disagree_are_refused(self) -> None: ), ) ) - with pytest.raises(ValueError, match=str(BAROQUE_PITCH)): + with pytest.raises(ProjectTuningError, match=str(BAROQUE_PITCH)) as refused: tuning_from_project(project) + + assert isinstance(refused.value, ValueError) From 8ca0fe046d3334802a4fbca0fc3eec8b6247d0dd Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 21:05:51 +0200 Subject: [PATCH 25/84] Fixed: the song reporting its end while still holding the output --- .../logic/sequencer/playback/song_player.py | 11 +- .../services/song_player/service.py | 43 +++++-- .../services/song_player/test_song_player.py | 105 ++++++++++++++++-- 3 files changed, 132 insertions(+), 27 deletions(-) diff --git a/src/sampletones_application/logic/sequencer/playback/song_player.py b/src/sampletones_application/logic/sequencer/playback/song_player.py index b4d289807..3205bb313 100644 --- a/src/sampletones_application/logic/sequencer/playback/song_player.py +++ b/src/sampletones_application/logic/sequencer/playback/song_player.py @@ -148,8 +148,8 @@ def relocate(self, order_position: int) -> None: def stop(self) -> None: """Silences the song and puts the playhead back at its start. - A stop of a song already silent is no transition, so it leaves ``on_change_audio_state`` - quiet. + ``on_change_audio_state`` fires when the song held the output, which is when a stop moves + it from one state to another. """ was_engaged = self._service.alive self._service.stop() @@ -230,10 +230,9 @@ def _emit_view(self) -> None: def _emit_idle_view(self) -> None: """Pushes a definitively stopped view. - ``SongPlaybackStopped`` is the authoritative end-of-playback signal, so the flags are - forced off here. The worker thread may still be closing its audio stream and briefly report - itself as playing; forcing the flags off keeps the stopped view authoritative and lets the - playing highlight settle correctly. + ``SongPlaybackStopped`` is the authoritative end-of-playback signal, and the service hands its + stream back before sending it, so the view reads the song silent and the playing highlight + settles. """ self.call( self.on_view_changed, diff --git a/src/sampletones_application/services/song_player/service.py b/src/sampletones_application/services/song_player/service.py index d58682587..c04fe9c8b 100644 --- a/src/sampletones_application/services/song_player/service.py +++ b/src/sampletones_application/services/song_player/service.py @@ -62,6 +62,7 @@ def __init__( self._master_gain = master_gain self._stop_event = threading.Event() self._resume_event = threading.Event() + self._holding_output = threading.Event() self._render_thread: Optional[threading.Thread] = None self._write_thread: Optional[threading.Thread] = None @@ -74,7 +75,12 @@ def __init__( @property def alive(self) -> bool: - return self._write_thread is not None and self._write_thread.is_alive() + """Whether the song holds the output, from :meth:`start` until the writer hands its stream back. + + The writer lets go before it reports the song's end, so a listener reading ``alive`` on + ``SongPlaybackStopped`` or ``SongPlaybackError`` reads the output free. + """ + return self._holding_output.is_set() @property def is_playing(self) -> bool: @@ -107,6 +113,7 @@ def start( self._write_block_frames = self._audio_device_manager.buffer_size self._stop_event.clear() self._resume_event.set() + self._holding_output.set() self._render_thread = threading.Thread( target=self._render_loop, daemon=True, @@ -161,9 +168,9 @@ def relocate(self, order_position: int) -> None: def _join_worker(self, thread: Optional[threading.Thread]) -> Optional[threading.Thread]: """Joins one worker; keeps the thread when it outlives the stop deadline. - Keeping a surviving writer is what makes ``alive`` report the truth: the thread still - holds the output stream, so callers waiting on quiescence — the audio device before it - tears the backend down — can see that the stream is still outstanding. + A surviving writer still holds the output stream, and ``alive`` goes on reporting it, so + callers waiting on quiescence — the audio device before it tears the backend down — can see + that the stream is still outstanding. """ if thread is None: return None @@ -196,18 +203,25 @@ def _render_loop(self) -> None: self._enqueue_end() def _write_loop(self) -> None: + """Drains the song to the device, hands the stream back, and then reports how the song ended.""" stream = self._open_stream() if stream is None: + self._holding_output.clear() + self._emit(SongPlaybackStopped()) return + ended = True try: - self._drain_to_stream(stream) + ended = self._drain_to_stream(stream) except Exception as exception: # pylint: disable=broad-exception-caught logger.error_with_traceback(exception, f"{self.class_name}: playback error") self._playback_error = exception - self._emit_terminal() finally: self._audio_device_manager.close_output_stream(stream) + self._holding_output.clear() + + if ended: + self._emit_terminal() def _open_stream(self) -> Optional[pyaudio.Stream]: try: @@ -221,27 +235,32 @@ def _open_stream(self) -> Optional[pyaudio.Stream]: return stream except Exception as exception: # pylint: disable=broad-exception-caught logger.error(f"{self.class_name}: failed to open audio stream: {exception}") - self._emit(SongPlaybackStopped()) self._stop_event.set() self._wake_buffer() return None - def _drain_to_stream(self, stream: pyaudio.Stream) -> None: + def _drain_to_stream(self, stream: pyaudio.Stream) -> bool: + """Hands the buffered rows to the device until the song ends or a stop comes. + + Returns: + True once the song reached its end, False when a stop ended the drain. + """ while not self._stop_event.is_set(): self._resume_event.wait() if self._stop_event.is_set(): - return + return False popped, row = self._dequeue() if not popped or self._stop_event.is_set(): - return + return False if row is None: - self._emit_terminal() - return + return True self._play_row(stream, row) + return False + def _play_row(self, stream: pyaudio.Stream, row: _RenderedRow) -> None: """Hands one row to the device, reporting its position once the whole row is written. diff --git a/tests/unit/sampletones_application/services/song_player/test_song_player.py b/tests/unit/sampletones_application/services/song_player/test_song_player.py index b92ef0065..1c825649f 100644 --- a/tests/unit/sampletones_application/services/song_player/test_song_player.py +++ b/tests/unit/sampletones_application/services/song_player/test_song_player.py @@ -183,7 +183,7 @@ def test_seek_does_nothing_when_not_alive(self) -> None: def test_seek_sets_synthesizer_position_when_alive(self) -> None: service = _make_service() - service._write_thread = MagicMock(is_alive=MagicMock(return_value=True)) + service._holding_output.set() service.seek(2) @@ -191,7 +191,7 @@ def test_seek_sets_synthesizer_position_when_alive(self) -> None: def test_seek_does_not_reset_voices(self) -> None: service = _make_service() - service._write_thread = MagicMock(is_alive=MagicMock(return_value=True)) + service._holding_output.set() service.seek(2) @@ -208,7 +208,7 @@ def test_relocate_does_nothing_when_not_alive(self) -> None: def test_relocate_keeps_current_row_when_alive(self) -> None: service = _make_service() - service._write_thread = MagicMock(is_alive=MagicMock(return_value=True)) + service._holding_output.set() service._synthesizer.row_index = 5 service.relocate(2) @@ -217,7 +217,7 @@ def test_relocate_keeps_current_row_when_alive(self) -> None: def test_relocate_does_not_reset_voices(self) -> None: service = _make_service() - service._write_thread = MagicMock(is_alive=MagicMock(return_value=True)) + service._holding_output.set() service._synthesizer.row_index = 0 service.relocate(2) @@ -392,7 +392,8 @@ def test_render_loop_ends_when_finished_without_loop(self) -> None: assert list(service._buffer) == [None] service._synthesizer.render_row.assert_not_called() - def test_drain_writes_buffered_rows_then_reports_stopped(self) -> None: + def test_drain_writes_buffered_rows_then_answers_the_end(self) -> None: + """The drain answers that the song ended, and the writer reports it once the stream is back.""" service = _make_service() received = [] service.subscribe(received.append) @@ -403,12 +404,12 @@ def test_drain_writes_buffered_rows_then_reports_stopped(self) -> None: service._buffer.append(None) mock_stream = MagicMock() - service._drain_to_stream(mock_stream) + ended = service._drain_to_stream(mock_stream) + assert ended is True mock_stream.write.assert_called_once() - assert isinstance(received[0], SongPositionUpdate) + assert [type(result) for result in received] == [SongPositionUpdate] assert received[0].position is position - assert isinstance(received[-1], SongPlaybackStopped) def test_drain_returns_without_terminal_when_stopping(self) -> None: service = _make_service() @@ -419,8 +420,9 @@ def test_drain_returns_without_terminal_when_stopping(self) -> None: service._buffer.append(_RenderedRow(chunk=np.ones(10, dtype=np.float32), position=SongPosition())) mock_stream = MagicMock() - service._drain_to_stream(mock_stream) + ended = service._drain_to_stream(mock_stream) + assert ended is False mock_stream.write.assert_not_called() assert received == [] @@ -510,6 +512,7 @@ def test_stop_returns_after_the_writer_closed_its_stream(self) -> None: def test_stop_keeps_a_worker_that_outlives_the_deadline(self) -> None: gate = threading.Event() service = _make_service() + service._holding_output.set() service._write_thread = _wedged_thread(gate) try: @@ -525,6 +528,7 @@ def test_start_is_refused_while_a_worker_still_holds_the_output(self) -> None: gate = threading.Event() audio_device_manager = _make_device_manager(_FakeStream()) service = _make_streaming_service(audio_device_manager) + service._holding_output.set() service._write_thread = _wedged_thread(gate) try: @@ -536,6 +540,89 @@ def test_start_is_refused_while_a_worker_still_holds_the_output(self) -> None: gate.set() +class TestSongPlayerServiceEndOfSong: + """The song lets go of the output before it reports how it ended, so a listener reads it free.""" + + def record_ends(self, service: SongPlayerService) -> Tuple[List[Tuple[SongPlayerResult, bool]], threading.Event]: + """Subscribes a listener noting each terminal result with whether the song held the output then.""" + ends: List[Tuple[SongPlayerResult, bool]] = [] + reported = threading.Event() + + def listen(result: SongPlayerResult) -> None: + if isinstance(result, (SongPlaybackStopped, SongPlaybackError)): + ends.append((result, service.alive)) + reported.set() + + service.subscribe(listen) + return ends, reported + + def test_the_output_is_held_from_the_start(self) -> None: + gate = threading.Event() + stream = _FakeStream(gate=gate) + service = _make_streaming_service(_make_device_manager(stream), rows=8) + service.subscribe(lambda result: None) + + service.start() + try: + assert service.alive is True + assert stream.entered_write.wait(timeout=WAIT_TIMEOUT) + assert service.alive is True + finally: + gate.set() + service.stop() + + def test_a_song_reaching_its_end_reports_it_with_the_output_free(self) -> None: + stream = _FakeStream() + service = _make_streaming_service(_make_device_manager(stream)) + ends, reported = self.record_ends(service) + + service.start() + + assert reported.wait(timeout=WAIT_TIMEOUT) + assert ends == [(SongPlaybackStopped(), False)] + assert stream.closed.is_set() + + def test_a_failing_write_reports_it_with_the_output_free(self) -> None: + error = OSError("device disappeared") + service = _make_streaming_service(_make_device_manager(_FakeStream(error=error))) + ends, reported = self.record_ends(service) + + service.start() + + assert reported.wait(timeout=WAIT_TIMEOUT) + assert ends == [(SongPlaybackError(error=error), False)] + + def test_a_stream_the_device_refuses_reports_the_stop_with_the_output_free(self) -> None: + audio_device_manager = _make_device_manager() + audio_device_manager.open_output_stream.side_effect = OSError("device busy") + service = _make_streaming_service(audio_device_manager) + ends, reported = self.record_ends(service) + + service.start() + + assert reported.wait(timeout=WAIT_TIMEOUT) + assert ends == [(SongPlaybackStopped(), False)] + + def test_a_stop_reports_no_end(self) -> None: + gate = threading.Event() + stream = _FakeStream(gate=gate) + service = _make_streaming_service(_make_device_manager(stream), rows=8) + ends, _ = self.record_ends(service) + + service.start() + assert stream.entered_write.wait(timeout=WAIT_TIMEOUT) + releaser = threading.Timer(WRITE_RELEASE_DELAY, gate.set) + releaser.start() + try: + service.stop() + finally: + releaser.cancel() + gate.set() + + assert ends == [] + assert service.alive is False + + class TestSongPlayerServiceStreamOwnership: """The device hands out a stream against a release, and gets it back when the writer finishes.""" From 3ac7eda6c837159c409fd7a2118108e58b511153 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 21:12:55 +0200 Subject: [PATCH 26/84] Fixed: Edit voice and the Converter's Load asking twice --- docs/development/application/dialogs.md | 5 +- src/sampletones_application/application.py | 47 ++++++++++----- .../coordinators/reconstruction.py | 40 +++++++------ .../utils/callbacks/gates.py | 25 ++++++-- .../coordinators/test_reconstruction.py | 49 ++++++++++----- .../test_application_gestures.py | 59 +++++++++++++++++++ .../utils/callbacks/test_gates.py | 45 ++++++++++++-- 7 files changed, 206 insertions(+), 64 deletions(-) diff --git a/docs/development/application/dialogs.md b/docs/development/application/dialogs.md index f9268fc44..c79b4c928 100644 --- a/docs/development/application/dialogs.md +++ b/docs/development/application/dialogs.md @@ -92,8 +92,9 @@ title bar's close and a save that failed turn it away. A request therefore alway While a conversation stands, the same gesture asked for again is absorbed, so two closes before the first is answered ask once. Once the conversation has ended, the gesture asks again. The span covers the wait for the edits of the open reconstruction too, so a gesture repeated while an edit is on its way asks once it lands. -`SingleFlight` (`utils/callbacks/gates.py`) holds the conversation, and the composition root wraps every such -gesture in one, whichever door it is asked for through. +`SingleFlight` (`utils/callbacks/gates.py`) holds the conversation, built from the request so a question can +speak of what the gesture asks for, and the composition root wraps every such gesture in one, whichever door it +is asked for through. ## Where it is written diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index b9a503cd4..7c1f0cbed 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1,6 +1,7 @@ from contextlib import ExitStack +from functools import partial from pathlib import Path -from typing import Any, Callable, Dict, Final, Optional, Tuple +from typing import Any, Callable, Dict, Final, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg from pydantic import ValidationError @@ -139,6 +140,7 @@ GestureParameters, GestureResult, SingleFlight, + fixed, gated, waiting, ) @@ -552,10 +554,7 @@ def __init__( on_reconstruct_directory=self._reconstruct_directory, on_load_reconstruction=self._reconstruction_opening, on_load_library=self._load_library, - on_load_file=gated( - self._reconstruction_coordinator.after_edits, - self._reconstruction_coordinator.load_converted, - ), + on_load_file=self._converted_loading_flight(), on_load_directory=self._navigate_to_reconstructions, on_canceled=self._refresh_browsers, on_refresh_trees=self._refresh_browsers, @@ -587,8 +586,8 @@ def __init__( language_manager=self.language_manager, dialogs=self.dialogs, status_bar=self.status_bar, - on_edit_voice_requested=gated( - self._reconstruction_coordinator.after_edits, + on_edit_voice_requested=self._document_flight( + self._reconstruction_coordinator.guard_edit_voice, self._reconstruction_coordinator.open_project_voice, ), on_favorite_changed=self._repaint_reconstruction_favorites, @@ -814,13 +813,27 @@ def _document_flight( ) -> SingleFlight[GestureParameters]: """A gesture on a whole document as one conversation: the edits on their way land, then ``guard`` asks.""" return SingleFlight( - ( - waiting(self._reconstruction_coordinator.after_edits), - guard, + fixed( + ( + waiting(self._reconstruction_coordinator.after_edits), + guard, + ) ), arrive, ) + def _converted_loading_flight(self) -> SingleFlight[[Path]]: + """Loading what a run wrote as one conversation, whose question speaks of the file it loads.""" + coordinator = self._reconstruction_coordinator + + def conversation(filepath: Path) -> Sequence[Gate]: + return ( + waiting(coordinator.after_edits), + partial(coordinator.guard_load_converted, filepath), + ) + + return SingleFlight(conversation, coordinator.load) + def _reconstruction_opening_flight(self) -> SingleFlight[[Optional[Path]]]: """Opening a reconstruction as one conversation, whichever door asks: the menu or a browser.""" return self._document_flight( @@ -1650,12 +1663,14 @@ def _exit_flight(self) -> SingleFlight[[]]: on any of them ends the conversation. """ return SingleFlight( - ( - waiting(self._reconstruction_coordinator.after_edits), - self._project_coordinator.guard_exit, - self._reconstruction_coordinator.guard_exit, - self._main_tab.guard_exit, - self._instructions_tab.guard_exit, + fixed( + ( + waiting(self._reconstruction_coordinator.after_edits), + self._project_coordinator.guard_exit, + self._reconstruction_coordinator.guard_exit, + self._main_tab.guard_exit, + self._instructions_tab.guard_exit, + ) ), self._exit_application, ) diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 697e1a7cc..4dfacf601 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -1,4 +1,3 @@ -from functools import partial from pathlib import Path from typing import Callable, Optional, Tuple @@ -29,7 +28,6 @@ TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, ) -from sampletones_application.utils.callbacks.gates import ignore from sampletones_application.utils.file_dialogs.api import ( open_file_dialog, save_file_dialog, @@ -239,29 +237,32 @@ def guard_load(self, proceed: VoidCallback, decline: VoidCallback) -> None: decline=decline, ) - def load_with_confirmation(self, filepath: Optional[Path] = None) -> None: - """Loads ``filepath``, or the file the reader picks, once unsaved changes are answered for.""" - self.guard_load(partial(self.open, filepath), ignore) - - def load_converted(self, filepath: Path) -> None: - """Loads the reconstruction a conversion wrote, asking first about unsaved changes. + def guard_load_converted( + self, + filepath: Path, + proceed: VoidCallback, + decline: VoidCallback, + ) -> None: + """Lets the reconstruction a conversion wrote at ``filepath`` take the open one's place. A conversion can write over the very file the open document came from, and a save would then write the old document over the new one. The question in that case is whether to discard the changes and load, and Cancel keeps them for a save to another file. Every other - document is loaded the way one opened by hand is. + document is asked about the way one opened by hand is. With ``filepath`` bound, the + signature is a :data:`Gate`, so the question leads the loading's conversation. """ if self._requires_save_confirmation() and self._reconstruction_manager.is_backed_by(filepath): self._dialogs.show_confirmation( tag=TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, message=self._language_manager["global.dialog.message.load_replaced_reconstruction"], title=self._language_manager["global.dialog.title.load_unsaved_reconstruction"], - on_confirm=lambda: self.load(filepath), + on_confirm=proceed, ok_label=self._language_manager["global.dialog.label.discard"], + on_cancel=decline, ) return - self.load_with_confirmation(filepath) + self.guard_load(proceed, decline) def load_reconstruction_safely(self, path: Path) -> None: """Loads the persisted reconstruction when the application starts. @@ -416,22 +417,23 @@ def _show_edit(self, edit: ReconstructionEdit) -> None: case StemRemoval(): self._tab.redraw_reconstruction(refit_waveform=False) - def open_project_voice(self, voice_id: str) -> None: - """Opens a voice of the project on the Reconstructions tab, in the terms of its kind. + def guard_edit_voice(self, proceed: VoidCallback, decline: VoidCallback) -> None: + """Lets a voice of the project take the open document's place, offering first to save unsaved changes. - Either kind takes the place of the open document, so a standalone document with unsaved - changes is offered a save first, the way loading a file offers it. + Either kind of voice takes the place of the open document, so a standalone document with + unsaved changes is offered a save first, the way loading a file offers it. The signature is a + :data:`Gate`, so the question leads the editing's conversation. """ self._save_first( title=self._language_manager["global.dialog.title.edit_voice_unsaved_reconstruction"], message=self._language_manager["global.dialog.message.edit_voice_unsaved_reconstruction"], ok_label=self._language_manager["global.dialog.label.discard"], - proceed=lambda: self._open_project_voice(voice_id), - decline=ignore, + proceed=proceed, + decline=decline, ) - def _open_project_voice(self, voice_id: str) -> None: - """Puts a voice of the project in front of the tab. + def open_project_voice(self, voice_id: str) -> None: + """Puts a voice of the project in front of the tab, in the terms of its kind. A sample opens as the reconstruction behind it, waveform and stems and all, and the document remembers the voice it is. An instrument stands on no recording, so the tab diff --git a/src/sampletones_application/utils/callbacks/gates.py b/src/sampletones_application/utils/callbacks/gates.py index 9fef9a448..36b5dd5bc 100644 --- a/src/sampletones_application/utils/callbacks/gates.py +++ b/src/sampletones_application/utils/callbacks/gates.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Callable, Generic, ParamSpec, Sequence, Tuple, TypeVar +from typing import Callable, Generic, ParamSpec, Sequence, TypeVar from sampletones_shared.types.callback import VoidCallback @@ -27,6 +27,20 @@ def gate(proceed: VoidCallback, _decline: VoidCallback) -> None: return gate +def fixed(gates: Sequence[Gate]) -> Callable[..., Sequence[Gate]]: + """A conversation that asks ``gates`` whatever a request carries. + + Args: + gates: What stands between every request and its arrival, in the order the gates are asked. + """ + held = tuple(gates) + + def conversation(*_args: object, **_kwargs: object) -> Sequence[Gate]: + return held + + return conversation + + def pass_gates( gates: Sequence[Gate], arrive: VoidCallback, @@ -78,7 +92,8 @@ def run() -> None: class SingleFlight(Generic[GestureParameters]): """A gesture that holds one conversation at a time, absorbing a repeat asked for while one is in flight. - The conversation is the gates the gesture passes. It is in flight from the moment the gesture is + The conversation is the gates the gesture passes, built from the request's arguments, so a + question can speak of what the request asks for. It is in flight from the moment the gesture is asked for until the gates let it through or turn it away. A gesture asked for twice before its question is answered therefore asks once, and one asked for after the answer asks again. A gate that raises ends the flight too, so one failure leaves the gesture to be asked for again. @@ -86,10 +101,10 @@ class SingleFlight(Generic[GestureParameters]): def __init__( self, - gates: Sequence[Gate], + conversation: Callable[GestureParameters, Sequence[Gate]], arrive: Callable[GestureParameters, GestureResult], ) -> None: - self._gates: Tuple[Gate, ...] = tuple(gates) + self._conversation = conversation self._arrive = arrive self._in_flight: bool = False @@ -109,7 +124,7 @@ def arrive() -> None: self._in_flight = True asked = False try: - pass_gates(self._gates, arrive, self._land) + pass_gates(self._conversation(*args, **kwargs), arrive, self._land) asked = True finally: if not asked: diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index dac362379..fd0c9b0d7 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from enum import Enum +from functools import partial from pathlib import Path from typing import Final, List, Optional, Tuple from unittest.mock import MagicMock, call, patch @@ -28,6 +29,7 @@ from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ServiceSuccess from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED +from sampletones_application.utils.callbacks.gates import ignore, pass_gates from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import SILENT_VOLUME @@ -117,6 +119,21 @@ def _undo(coordinator: ReconstructionCoordinator, history: HistoryManager) -> No coordinator.after_edits(history.undo) +def _load_by_hand(coordinator: ReconstructionCoordinator, filepath: Path) -> None: + """Asks to open ``filepath`` the way the reconstruction menu does, past the question it raises.""" + pass_gates((coordinator.guard_load,), partial(coordinator.open, filepath), ignore) + + +def _load_converted(coordinator: ReconstructionCoordinator, filepath: Path) -> None: + """Asks to load what a conversion wrote the way the Converter's Load does, past the question it raises.""" + pass_gates((partial(coordinator.guard_load_converted, filepath),), partial(coordinator.load, filepath), ignore) + + +def _edit_voice(coordinator: ReconstructionCoordinator, voice_id: str) -> None: + """Asks to edit a voice of the project the way the Sequencer does, past the question it raises.""" + pass_gates((coordinator.guard_edit_voice,), partial(coordinator.open_project_voice, voice_id), ignore) + + def _save(coordinator: ReconstructionCoordinator) -> None: """The reader pressing Save reconstruction, which the application holds until the edits before it land.""" coordinator.after_edits(coordinator.save) @@ -478,7 +495,7 @@ def test_load_prompts_only_for_standalone_unsaved( ) path = Path("lead.stn") - coordinator.load_with_confirmation(path) + _load_by_hand(coordinator, path) if test_case.expects_prompt: coordinator._dialogs.show_save_confirmation.assert_called_once() @@ -589,7 +606,7 @@ def open_sample( """A sample added to the project and opened on the tab, with the calls opening it cleared.""" with history.transaction(HistoryAction.ADD_SAMPLE): sample = project_controller.add_sample(reconstruction_factory(), "lead") - following_coordinator.open_project_voice(sample.id) + _edit_voice(following_coordinator, sample.id) held_queue.drain() tab.reset_mock() return sample @@ -717,7 +734,7 @@ def test_a_redo_taking_the_sample_out_closes_it( with history.transaction(HistoryAction.REMOVE_VOICE): project_controller.remove_voice(open_sample.id) history.undo() - following_coordinator.open_project_voice(open_sample.id) + _edit_voice(following_coordinator, open_sample.id) history.redo() @@ -892,7 +909,7 @@ def test_a_sample_opens_as_the_document_it_is( sample = project_controller.project.voice(voice_id) assert isinstance(sample, Sample) - following_coordinator.open_project_voice(voice_id) + _edit_voice(following_coordinator, voice_id) assert reconstruction_manager.voice_id == voice_id assert reconstruction_manager.reconstruction is sample.reconstruction @@ -908,7 +925,7 @@ def test_an_instrument_opens_in_the_editor_on_its_tab( ) -> None: voice_id = self._add_voice(VoiceKind.INSTRUMENT, project_controller, history, reconstruction_factory) - following_coordinator.open_project_voice(voice_id) + _edit_voice(following_coordinator, voice_id) tab.edit_instrument.assert_called_once_with(voice_id) following_coordinator._on_tab_switch.assert_called_once_with(Tab.RECONSTRUCTIONS) @@ -919,7 +936,7 @@ def test_an_unknown_voice_opens_nothing( reconstruction_manager: ReconstructionManager, tab: MagicMock, ) -> None: - following_coordinator.open_project_voice("gone") + _edit_voice(following_coordinator, "gone") assert reconstruction_manager.current_reconstruction is None tab.edit_instrument.assert_not_called() @@ -943,7 +960,7 @@ def test_an_unsaved_standalone_document_is_offered_a_save_first( voice_id = self._add_voice(test_case.kind, project_controller, history, reconstruction_factory) reconstruction_manager.mark_updated() - following_coordinator.open_project_voice(voice_id) + _edit_voice(following_coordinator, voice_id) following_coordinator._dialogs.show_save_confirmation.assert_called_once() assert reconstruction_manager.filepath == standalone_path @@ -967,7 +984,7 @@ def test_the_answer_opens_the_voice( ) -> None: voice_id = self._add_voice(test_case.kind, project_controller, history, reconstruction_factory) reconstruction_manager.mark_updated() - following_coordinator.open_project_voice(voice_id) + _edit_voice(following_coordinator, voice_id) following_coordinator._dialogs.show_save_confirmation.call_args.kwargs["on_confirm"]() @@ -990,7 +1007,7 @@ def test_an_edited_project_sample_opens_another_voice_at_once( voice_id = self._add_voice(VoiceKind.SAMPLE, project_controller, history, reconstruction_factory) reconstruction_manager.mark_updated() - following_coordinator.open_project_voice(voice_id) + _edit_voice(following_coordinator, voice_id) following_coordinator._dialogs.show_save_confirmation.assert_not_called() assert reconstruction_manager.voice_id == voice_id @@ -1259,7 +1276,7 @@ def test_the_question_put_before_loading( ) -> None: coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) - coordinator.load_converted(self._converted(test_case, tmp_path)) + _load_converted(coordinator, self._converted(test_case, tmp_path)) assert self._asked(coordinator) == test_case.expected @@ -1277,7 +1294,7 @@ def test_going_on_loads_what_the_conversion_wrote( coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) converted = self._converted(test_case, tmp_path) - coordinator.load_converted(converted) + _load_converted(coordinator, converted) asked = self._asked(coordinator) if asked == SAVE_PROMPT: coordinator._dialogs.show_save_confirmation.call_args.kwargs["on_confirm"]() @@ -1299,7 +1316,7 @@ def test_nothing_loads_before_the_answer( ) -> None: coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) - coordinator.load_converted(self._converted(test_case, tmp_path)) + _load_converted(coordinator, self._converted(test_case, tmp_path)) coordinator._tab.load_reconstruction.assert_not_called() assert coordinator.is_unsaved() @@ -1313,7 +1330,7 @@ def test_a_replaced_file_offers_to_discard_the_changes( test_case = next(test_case for test_case in self.test_cases if test_case.expected == REPLACED_PROMPT) coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) - coordinator.load_converted(self._converted(test_case, tmp_path)) + _load_converted(coordinator, self._converted(test_case, tmp_path)) prompt = coordinator._dialogs.show_confirmation.call_args.kwargs assert prompt["message"] == REPLACED_MESSAGE_KEY @@ -1327,7 +1344,7 @@ def test_the_save_prompt_saves_the_open_document( ) -> None: test_case = next(test_case for test_case in self.test_cases if test_case.expected == SAVE_PROMPT) coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) - coordinator.load_converted(self._converted(test_case, tmp_path)) + _load_converted(coordinator, self._converted(test_case, tmp_path)) outcome = coordinator._dialogs.show_save_confirmation.call_args.kwargs["on_save"]() @@ -1403,7 +1420,7 @@ def turns_sample( """The two-recording document added to the project and opened on the tab.""" with history.transaction(HistoryAction.ADD_SAMPLE): sample = project_controller.add_sample(taking_turns, "turns") - following_coordinator.open_project_voice(sample.id) + _edit_voice(following_coordinator, sample.id) held_queue.drain() tab.reset_mock() return sample @@ -1497,7 +1514,7 @@ def test_opening_another_voice_mid_flight_leaves_it_as_it_was( original = other.reconstruction entries = len(history.entries) _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) - following_coordinator.open_project_voice(other.id) + _edit_voice(following_coordinator, other.id) held_queue.drain() sample = project_controller.project.voice(other.id) diff --git a/tests/unit/sampletones_application/test_application_gestures.py b/tests/unit/sampletones_application/test_application_gestures.py index 608dc1242..24ed19052 100644 --- a/tests/unit/sampletones_application/test_application_gestures.py +++ b/tests/unit/sampletones_application/test_application_gestures.py @@ -289,3 +289,62 @@ def test_a_browser_and_the_menu_share_one_opening( proceed, _ = guard.call_args.args proceed() app._reconstruction_coordinator.open.assert_called_once_with(Path("browsed.stn")) + + +class TestLoadingWhatARunWroteAsksOnce: + """The Converter's Load asks about the file it loads once, however often it is pressed meanwhile.""" + + @pytest.fixture + def guard(self, app: Application) -> MagicMock: + guard = app._reconstruction_coordinator.guard_load_converted + guard.side_effect = None + return guard + + def test_two_presses_while_an_edit_is_on_its_way_ask_once( + self, + app: Application, + guard: MagicMock, + held_gate: HeldGate, + ) -> None: + loading = app._converted_loading_flight() + + loading(Path("written.stn")) + loading(Path("written.stn")) + held_gate.release() + + guard.assert_called_once() + assert guard.call_args.args[0] == Path("written.stn") + app._reconstruction_coordinator.load.assert_not_called() + + def test_the_answer_loads_the_file_the_question_spoke_of( + self, + app: Application, + guard: MagicMock, + held_gate: HeldGate, + ) -> None: + loading = app._converted_loading_flight() + loading(Path("written.stn")) + held_gate.release() + + _, proceed, _ = guard.call_args.args + proceed() + + app._reconstruction_coordinator.load.assert_called_once_with(Path("written.stn")) + + def test_a_press_after_cancel_asks_again( + self, + app: Application, + guard: MagicMock, + held_gate: HeldGate, + ) -> None: + loading = app._converted_loading_flight() + loading(Path("written.stn")) + held_gate.release() + _, _, decline = guard.call_args.args + + decline() + loading(Path("written.stn")) + held_gate.release() + + assert guard.call_count == 2 + app._reconstruction_coordinator.load.assert_not_called() diff --git a/tests/unit/sampletones_application/utils/callbacks/test_gates.py b/tests/unit/sampletones_application/utils/callbacks/test_gates.py index bd6ca2c82..5bf3f4de9 100644 --- a/tests/unit/sampletones_application/utils/callbacks/test_gates.py +++ b/tests/unit/sampletones_application/utils/callbacks/test_gates.py @@ -3,7 +3,7 @@ import pytest -from sampletones_application.utils.callbacks.gates import SingleFlight, gated, pass_gates, waiting +from sampletones_application.utils.callbacks.gates import Gate, SingleFlight, fixed, gated, pass_gates, waiting from sampletones_shared.types.callback import VoidCallback ARRIVED: Final[str] = "arrived" @@ -166,7 +166,7 @@ def guard_fixture(self, reached: List[str]) -> Guard: @pytest.fixture(name="flight") def flight_fixture(self, guard: Guard, reached: List[str]) -> SingleFlight[[]]: - return SingleFlight((guard,), lambda: reached.append(ARRIVED)) + return SingleFlight(fixed((guard,)), lambda: reached.append(ARRIVED)) def test_a_repeat_while_the_question_stands_is_absorbed( self, @@ -225,7 +225,7 @@ def test_a_gesture_after_a_decline_asks_again( def test_a_repeat_while_a_wait_holds_the_gesture_is_absorbed(self, reached: List[str]) -> None: wait = HeldWait() guard = Guard("question", reached, unfinished=True) - flight: SingleFlight[[]] = SingleFlight((waiting(wait), guard), lambda: reached.append(ARRIVED)) + flight: SingleFlight[[]] = SingleFlight(fixed((waiting(wait), guard)), lambda: reached.append(ARRIVED)) flight() flight() @@ -236,7 +236,7 @@ def test_a_repeat_while_a_wait_holds_the_gesture_is_absorbed(self, reached: List def test_the_arrival_takes_the_arguments_of_the_gesture_that_asked(self, reached: List[str]) -> None: guard = Guard("question", reached, unfinished=True) opened: List[Path] = [] - flight: SingleFlight[[Path]] = SingleFlight((guard,), opened.append) + flight: SingleFlight[[Path]] = SingleFlight(fixed((guard,)), opened.append) flight(Path("first.stn")) flight(Path("second.stn")) @@ -244,9 +244,42 @@ def test_the_arrival_takes_the_arguments_of_the_gesture_that_asked(self, reached assert opened == [Path("first.stn")] + def test_the_question_is_built_from_the_gesture_that_asked(self, reached: List[str]) -> None: + """A question speaking of what the gesture asks for is built from that gesture's arguments.""" + guards: List[Guard] = [] + + def conversation(path: Path) -> List[Gate]: + guard = Guard(path.name, reached, unfinished=True) + guards.append(guard) + return [guard] + + opened: List[Path] = [] + flight: SingleFlight[[Path]] = SingleFlight(conversation, opened.append) + + flight(Path("first.stn")) + flight(Path("second.stn")) + guards[0].answer() + flight(Path("third.stn")) + + assert reached == ["first.stn", "third.stn"] + assert opened == [Path("first.stn")] + assert flight.in_flight + + def test_a_conversation_that_raises_ends_the_flight(self, reached: List[str]) -> None: + def conversation() -> List[Gate]: + raise RuntimeError("the question could not be built") + + flight: SingleFlight[[]] = SingleFlight(conversation, lambda: reached.append(ARRIVED)) + + with pytest.raises(RuntimeError): + flight() + + assert not flight.in_flight + assert not reached + def test_a_gesture_with_nothing_to_ask_arrives_and_lands(self, reached: List[str]) -> None: flight: SingleFlight[[]] = SingleFlight( - (Guard("clear", reached, unfinished=False),), + fixed((Guard("clear", reached, unfinished=False),)), lambda: reached.append(ARRIVED), ) @@ -260,7 +293,7 @@ def broken(_proceed: VoidCallback, _decline: VoidCallback) -> None: reached.append("broken") raise RuntimeError("the question could not be asked") - flight: SingleFlight[[]] = SingleFlight((broken,), lambda: reached.append(ARRIVED)) + flight: SingleFlight[[]] = SingleFlight(fixed((broken,)), lambda: reached.append(ARRIVED)) with pytest.raises(RuntimeError): flight() From 1dcbf36b4d302de0bf42ba842e96361669657835 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 21:15:16 +0200 Subject: [PATCH 27/84] Fixed: the exit running work left in its frame and losing the failure the run ended on --- src/sampletones_application/application.py | 38 +++++++++++-------- .../test_application_exit.py | 37 +++++++++++++++++- .../sampletones_application/test_startup.py | 26 +++++++++++-- 3 files changed, 81 insertions(+), 20 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 7c1f0cbed..07b397ab8 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1,7 +1,7 @@ -from contextlib import ExitStack +from contextlib import ExitStack, contextmanager from functools import partial from pathlib import Path -from typing import Any, Callable, Dict, Final, Optional, Sequence, Tuple +from typing import Any, Callable, Dict, Final, Iterator, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg from pydantic import ValidationError @@ -1679,7 +1679,12 @@ def _is_project_open(self) -> bool: return self.project_controller.is_open def _exit_application(self) -> None: - """Stops the frames; the run lets go of what it holds once its loop has ended.""" + """Stops the frames and the work waiting on the render thread, in the frame the exit is decided. + + The run lets go of what it holds once its loop has ended, so the session it writes is the one + the reader left. + """ + CallbackQueue.stop() dpg.stop_dearpygui() def _update_status(self) -> None: @@ -1736,27 +1741,30 @@ def _save_config(self) -> None: def run(self) -> None: claim_render_thread() - try: - while dpg.is_dearpygui_running(): - self.frame() - self._post_frame() - self.frame_limiter.tick() - except KeyboardInterrupt: - return - finally: - self._teardown() + with self._teardown(): + try: + while dpg.is_dearpygui_running(): + self.frame() + self._post_frame() + self.frame_limiter.tick() + except KeyboardInterrupt: + return - def _teardown(self) -> None: - """Lets go of everything the run holds, once its loop has ended, whichever way it ended. + @contextmanager + def _teardown(self) -> Iterator[None]: + """Lets go of everything the run holds once the block it wraps has ended, whichever way it ended. Every step is taken whatever an earlier one raised, so a failure leaves the background work stopped, the audio backend closed and the DearPyGui context destroyed, and it is raised once - the last step has run. + the last step has run. A step failing after the block raised carries the block's failure as + its context, so the traceback names what ended the run. """ with ExitStack() as steps: for step in reversed(self._teardown_steps()): steps.callback(step) + yield + def _teardown_steps(self) -> Tuple[VoidCallback, ...]: """The steps of the teardown in the order they are taken. diff --git a/tests/unit/sampletones_application/test_application_exit.py b/tests/unit/sampletones_application/test_application_exit.py index 6ff03e459..7acde8ef8 100644 --- a/tests/unit/sampletones_application/test_application_exit.py +++ b/tests/unit/sampletones_application/test_application_exit.py @@ -1,9 +1,10 @@ -from typing import Final, List, Optional, Tuple -from unittest.mock import MagicMock +from typing import Final, Iterator, List, Optional, Tuple +from unittest.mock import MagicMock, patch import pytest from sampletones_application.application import Application +from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_shared.types.callback import VoidCallback PROJECT: Final[str] = "project" @@ -261,3 +262,35 @@ def test_two_closes_while_an_edit_is_on_its_way_ask_once_it_lands(self, exiting: exiting.owners[RECONSTRUCTION].land() assert exiting.asked == [RECONSTRUCTION] + + +class TestTheFrameTheExitIsDecidedIn: + """The work waiting on the render thread stays unrun once the exit is decided, so nothing the reader + left behind starts after they chose to leave.""" + + @pytest.fixture(autouse=True) + def live_queue(self) -> Iterator[None]: + CallbackQueue.start() + yield + CallbackQueue.stop() + CallbackQueue.start() + + def test_the_work_due_in_that_frame_stays_unrun(self) -> None: + application = Application.__new__(Application) + ran: List[str] = [] + CallbackQueue.add(lambda: ran.append("late")) + + with patch("dearpygui.dearpygui.stop_dearpygui") as stop_dearpygui: + application._exit_application() + CallbackQueue.process(budget_seconds=1.0) + + stop_dearpygui.assert_called_once_with() + assert not ran + + def test_the_work_due_before_the_exit_runs(self) -> None: + ran: List[str] = [] + CallbackQueue.add(lambda: ran.append("due")) + + CallbackQueue.process(budget_seconds=1.0) + + assert ran == ["due"] diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 16de19fa9..6fa9fab3c 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -275,8 +275,8 @@ def _remember_device(directory: Path) -> None: @staticmethod def _leave(application: Application) -> None: """Takes the teardown a run takes once its loop has ended, leaving the context to the fixture.""" - with patch("dearpygui.dearpygui.destroy_context") as destroy_context: - application._teardown() + with patch("dearpygui.dearpygui.destroy_context") as destroy_context, application._teardown(): + pass destroy_context.assert_called_once_with() @@ -332,13 +332,33 @@ def test_a_failing_step_leaves_the_later_ones_taken(self, tmp_path: Path) -> Non patch.object(application._main_tab, "cleanup", side_effect=RuntimeError), patch("dearpygui.dearpygui.destroy_context") as destroy_context, pytest.raises(RuntimeError), + application._teardown(), ): - application._teardown() + pass destroy_context.assert_called_once_with() assert application.audio_device_manager._pyaudio is None assert _profile(tmp_path).state.exists() + def test_a_failing_step_keeps_the_failure_that_ended_the_run(self, tmp_path: Path) -> None: + """The traceback of a step failing on the way out names the failure the run ended on.""" + with ExitStack() as stack: + for display_patch in _display_patches(): + stack.enter_context(display_patch) + stack.enter_context(_viewport_geometry()) + stack.enter_context(_no_audio_devices()) + + application = _application(tmp_path) + with ( + patch.object(application._main_tab, "cleanup", side_effect=RuntimeError("cleanup")), + patch("dearpygui.dearpygui.destroy_context"), + pytest.raises(RuntimeError) as raised, + application._teardown(), + ): + raise ValueError("the run") + + assert isinstance(raised.value.__context__, ValueError) + @pytest.fixture def app(tmp_path: Path) -> Generator[Any, Application, Any]: From eab23e0eadfa2233c43fe082abc28dde7ebbe24c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 21:15:44 +0200 Subject: [PATCH 28/84] Fixed: a file opened from the command line remembered by a relative path --- src/sampletones/commands/open.py | 10 +++++++--- tests/unit/sampletones/commands/test_open.py | 21 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/sampletones/commands/open.py b/src/sampletones/commands/open.py index 9457c97bd..d65cb121a 100644 --- a/src/sampletones/commands/open.py +++ b/src/sampletones/commands/open.py @@ -27,6 +27,9 @@ def configure(parser: ArgumentParser) -> None: def run(arguments: Namespace) -> int: """Starts the application with the file loaded. + The file is handed on by its absolute path, so the session that remembers it reopens it from + any folder a later run starts in. + Raises: SystemExit: If the path names no file, a recording, or a file of another kind. """ @@ -56,11 +59,12 @@ def run(arguments: Namespace) -> int: from sampletones_shared.array import report_array_backend report_array_backend() + opened = given.path.resolve() run_application( given.config, - project_path=given.path if suffix == EXT_FILE_PROJECT else None, - reconstruction_path=given.path if suffix == EXT_FILE_RECONSTRUCTION else None, - library_path=given.path if suffix == EXT_FILE_LIBRARY else None, + project_path=opened if suffix == EXT_FILE_PROJECT else None, + reconstruction_path=opened if suffix == EXT_FILE_RECONSTRUCTION else None, + library_path=opened if suffix == EXT_FILE_LIBRARY else None, ) return 0 diff --git a/tests/unit/sampletones/commands/test_open.py b/tests/unit/sampletones/commands/test_open.py index 48569d622..0d9441f90 100644 --- a/tests/unit/sampletones/commands/test_open.py +++ b/tests/unit/sampletones/commands/test_open.py @@ -49,6 +49,27 @@ def test_a_file_is_loaded_by_its_kind( key for key in ("library", "reconstruction", "project") if key != field ] + @pytest.mark.parametrize( + ("name", "field"), + [("song.stp", "project"), ("song.stn", "reconstruction"), ("library.ins", "library")], + ) + def test_a_file_named_from_the_working_folder_is_handed_on_whole( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + name: str, + field: str, + ) -> None: + """The session remembers the file the run opened, so a later run reopens it from any folder.""" + application = RecordedApplication() + monkeypatch.setattr(LAUNCHER, application) + empty_file(tmp_path, name) + monkeypatch.chdir(tmp_path) + + assert dispatch(COMMANDS, ["open", name]) == 0 + + assert application.starts[0][field] == (tmp_path / name).resolve() + def test_a_recording_is_pointed_at_convert(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: application = RecordedApplication() monkeypatch.setattr(LAUNCHER, application) From 39da8b888ed74e4f6946cd31fe39e4f301cffd91 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 21:18:53 +0200 Subject: [PATCH 29/84] Fixed: a missed release holding the bar graph's press and a returning waveform layer drawn out of order --- .../ui/elements/graphs/bar.py | 14 +++++++++- .../ui/elements/graphs/waveform.py | 23 +++++++++++----- .../logic/main/sources/test_scan.py | 15 +++++++++++ .../ui/elements/graphs/test_bar.py | 23 ++++++++++++++++ .../ui/elements/graphs/test_waveform.py | 27 +++++++++++++++++-- 5 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/sampletones_application/ui/elements/graphs/bar.py b/src/sampletones_application/ui/elements/graphs/bar.py index f0f93d0a2..d60a13b2a 100644 --- a/src/sampletones_application/ui/elements/graphs/bar.py +++ b/src/sampletones_application/ui/elements/graphs/bar.py @@ -385,9 +385,21 @@ def _on_mouse_action(self, _sender: Sender) -> None: self._set_hover_bar_position(bar_index, clamped_y) self.call(self.on_bar_point_hovered, name, bar_index) - if self._pressed and self._presses_a_bar(mouse_y): + if self._holds_press() and self._presses_a_bar(mouse_y): self._draw_bar(layer, bar_index, clamped_y, previous_stroke) + def _holds_press(self) -> bool: + """Whether a press that went down on the plot is still held. + + A release the plot never heard, such as one outside the window, ends the press here, once the + button reads up, so a later press begun elsewhere draws nothing. + """ + if self._pressed and not dpg.is_mouse_button_down(dpg.mvMouseButton_Left): + self._pressed = False + self._draw_stroke = None + + return self._pressed + def _presses_a_bar(self, mouse_y: float) -> bool: """Whether a press at ``mouse_y`` stands on the grid the bars are drawn across. diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index 6057dd07b..26a24a594 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -503,14 +503,19 @@ def update_waveform_data( """Redraws the loaded waveform from fresh data, keeping the view the reader left it at. The plot draws the layers the fresh data displays, so a layer the data no longer carries - leaves it. ``refit`` names the update a retune is: the audio's own length changed, so the - view is re-fitted to the new span. + leaves it, and a layer joining it is drawn in the layers' order, with the audible source on + top. ``refit`` names the update a retune is: the audio's own length changed, so the view is + re-fitted to the new span. """ if not isinstance(self.current_data, WaveformData): return self.current_data = waveform_data - self.layers = {layer.name: layer for layer in self._display_layers(waveform_data, selected_channels)} + displayed = self._display_layers(waveform_data, selected_channels) + if any(layer.name not in self.layers for layer in displayed): + self._delete_series() + + self.layers = {layer.name: layer for layer in displayed} if refit: self._update_ranges() @@ -599,15 +604,21 @@ def _reorder_series(self) -> None: sample_layer = self.layers[self._lbl_waveform_original] reconstruction_layer = self.layers[self._lbl_waveform_reconstruction] - for layer in (sample_layer, reconstruction_layer): - dpg_delete_item(self._series_tag(layer.name)) - + self._delete_series() self.layers.clear() for layer in self._ordered_layers(sample_layer, reconstruction_layer): self.layers[layer.name] = layer self._update_display() + def _delete_series(self) -> None: + """Takes every layer's series off the axis, so the next display adds them back in the layers' order. + + DearPyGui draws sibling series in child order, and a series it already holds keeps its place. + """ + for layer_name in self.layers: + dpg_delete_item(self._series_tag(layer_name)) + def clear(self) -> None: """Empties the plot down to the marks it keeps for whatever it draws next: the position indicator and the overlay rectangle, both of which live among the axis's children.""" diff --git a/tests/unit/sampletones_application/logic/main/sources/test_scan.py b/tests/unit/sampletones_application/logic/main/sources/test_scan.py index 7f2aefeb8..f83a6cc8d 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_scan.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_scan.py @@ -277,6 +277,21 @@ def test_a_walk_finishing_before_it_heard_stop_answers_nobody( assert answered == [] assert heard.stopped == 1 + def test_a_walk_failing_as_it_winds_down_ends_the_way_a_stopped_one_does( + self, + stopped: FolderScan, + service: ScanServiceStandIn, + heard: Heard, + ) -> None: + """The reader gave the reading up, so its failure reads as the stop, and the folder asked for meanwhile is read.""" + stopped.start(FEW, lambda _root, _found: None) + + service.report(FolderScanError(request=service.latest, exception=OSError("the tree went away"))) + + assert heard.failures == [] + assert heard.stopped == 1 + assert [request.root for request in service.started] == [MANY, FEW] + def test_a_late_report_of_the_reading_let_go_is_set_aside( self, stopped: FolderScan, diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py b/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py index 4c1ccc2ef..af93e32c3 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py @@ -71,6 +71,7 @@ def _point_at( monkeypatch.setattr(bar_module, "dpg_configure_item", lambda tag, **kwargs: None) monkeypatch.setattr(bar_module.dpg, "is_key_down", lambda key: False) monkeypatch.setattr(bar_module.dpg, "get_plot_mouse_pos", lambda: position) + monkeypatch.setattr(bar_module.dpg, "is_mouse_button_down", lambda button: True) def _press( @@ -93,6 +94,17 @@ def _hover( graph._on_mouse_action(PLOT_TAG) +def _hover_with_the_button_up( + graph: GUIBarGraph, + monkeypatch: pytest.MonkeyPatch, + position: Tuple[float, float], +) -> None: + """One frame of the pointer standing at ``position`` with the left button up.""" + _point_at(monkeypatch, position) + monkeypatch.setattr(bar_module.dpg, "is_mouse_button_down", lambda button: False) + graph._on_mouse_action(PLOT_TAG) + + def _with_bar(index: int, value: float) -> List[int]: """The plotted values with one bar written.""" expected = list(BAR_VALUES) @@ -162,6 +174,17 @@ def test_a_release_ends_the_press(self, monkeypatch: pytest.MonkeyPatch) -> None expected[last_bar] = int(PRESSED_VALUE) assert list(graph.layers[LAYER_NAME].y_data) == expected + def test_a_release_the_plot_missed_ends_the_press(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A button that reads up ends the press, so a button held again later draws nothing.""" + graph = _graph() + last_bar = len(BAR_VALUES) - 1 + _press(graph, monkeypatch, (PRESSED_BAR + 0.5, PRESSED_VALUE)) + + _hover_with_the_button_up(graph, monkeypatch, (last_bar + 0.5, PRESSED_VALUE)) + _hover(graph, monkeypatch, (last_bar + 0.5, PRESSED_VALUE)) + + assert list(graph.layers[LAYER_NAME].y_data) == _with_bar(PRESSED_BAR, PRESSED_VALUE) + class TestPressingTheBandBeneathTheBars: """A band reserved beneath the bars reads which stretch belongs to whom. diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py index 0180c36bd..ab28081d0 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -1,5 +1,5 @@ from types import SimpleNamespace -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Tuple, Union from unittest.mock import MagicMock import numpy as np @@ -47,7 +47,8 @@ def get_item_children(self, tag: str, slot: int) -> List[int]: def get_item_alias(self, item_id: int) -> str: return self.id_to_alias.get(item_id, "") - def delete_item(self, item_id: int) -> None: + def delete_item(self, item: Union[int, str]) -> None: + item_id = self.alias_to_id.get(item, -1) if isinstance(item, str) else item self.deleted.append(item_id) for children in self.children.values(): if item_id in children: @@ -258,6 +259,28 @@ def test_layers_the_data_still_displays_stay( assert (list(graph.layers), fake_dpg.deleted) == (["Original", "Reconstruction"], []) + def test_a_layer_joining_the_plot_is_drawn_in_the_layers_order( + self, + fake_dpg: _FakeDPG, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The series already drawn is taken off and added back behind the one that joins, as ordered.""" + graph = _graph() + graph.current_data = TestWaveformDataUpdateRefit._waveform_data() + graph.layers = {"Reconstruction": _Layer("Reconstruction")} + fake_dpg.set_children("axis", [graph._series_tag("Reconstruction"), "indicator", "overlay"]) + added: List[str] = [] + monkeypatch.setattr(waveform_module.dpg, "add_line_series", lambda *args, **kwargs: added.append(kwargs["tag"])) + monkeypatch.setattr( + graph, + "_display_layers", + lambda *_args, **_kwargs: [_Layer("Reconstruction"), _Layer("Original")], + ) + + graph.update_waveform_data(TestWaveformDataUpdateRefit._waveform_data()) + + assert added == [graph._series_tag("Reconstruction"), graph._series_tag("Original")] + class TestWaveformReconstructionDim: def test_series_color_is_untouched_when_not_dimmed(self) -> None: From 908e0f0a118fb5bee736c061f998a8c6ec723be6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 21:21:16 +0200 Subject: [PATCH 30/84] Corrected: the docs and docstrings the review found out of step with the fixes --- docs/development/application/render-thread.md | 10 +++++----- docs/development/architecture.md | 2 +- .../coordinators/reconstruction.py | 2 +- .../coordinators/tabs/sequencer/voices.py | 4 ++-- .../logic/sequencer/history_detail.py | 8 +++++--- .../utils/callbacks/gates.py | 2 +- .../utils/gui/render_thread.py | 16 ++++++++-------- 7 files changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index 9202ab9fb..dac740a4e 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -25,9 +25,9 @@ belongs to the visual layers. ## Work arriving from a worker crosses through `on_render_thread` -A thread of our own, such as a directory being read or a subtree being rebuilt, reaches the interface while -the render thread is walking the very items it would create and drop. An item freed there is freed with no -Python thread state, which crashes the process. +A thread of our own, such as a subtree being rebuilt, reaches the interface while the render thread is +walking the very items it would create and drop. An item freed there is freed with no Python thread state, +which crashes the process. `on_render_thread` (`utils/gui/render_thread.py`) is the crossing. Work already on the render thread runs where it stands, and work arriving from any other thread joins the queue. A worker that reads a value or @@ -38,8 +38,8 @@ interface is being built, the work runs in place. The thread that drew keeps the context after the loop stops, until the context is destroyed, since the teardown runs there. A worker's work that arrives after the loop has stopped joins the queue like any -other, and the teardown stops the queue, so that work is let go of. The context being taken down therefore -stays with the thread taking it down. A worker reading something long, such as a folder, also listens for +other, and nothing drains the queue once the loop has stopped, so that work never runs. The context being +taken down therefore stays with the thread taking it down. A worker reading something long, such as a folder, also listens for the shutdown and gives up at its next step, so the teardown finds it ended. ## A widget's own gesture is held for the frame diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 9644c0714..4aabc5d88 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -205,7 +205,7 @@ Runs long operations (file conversion, waveform regeneration, export, playback s - Every service inherits `ServiceBase[ResultType]`, which provides `subscribe(handler)`, `unsubscribe(handler)` and `_emit(result)`. - `_emit` posts the result to `CallbackQueue`, which puts every handler on the render thread (principle 6). -- Result types are a tagged union of `ServiceStarted`, `ServiceProgress`, `ServiceIntermediate`, `ServiceSuccess`, `ServiceError` and `ServiceCanceled`, so a subscriber matches exhaustively. +- Result types are a tagged union, so a subscriber matches exhaustively. A long operation reports through `ServiceStarted`, `ServiceProgress`, `ServiceIntermediate`, `ServiceSuccess`, `ServiceError` and `ServiceCanceled`, and a service whose reports carry more, such as the playhead or the request they answer, declares a union of its own in its `result.py`. - A service is one subpackage holding `service.py` and `result.py`, so its implementation and the contract its subscribers type against are reached separately. The generic contracts every service reports through are in `services/result.py`. `ServiceProgress.fraction` is the one reading a bar draws. See [`progress.md`](progress.md). - A service knows no panel, view model or logic object. diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 4dfacf601..e10ac8768 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -354,7 +354,7 @@ def after_edits(self, gesture: VoidCallback) -> None: Undo, a save, a load or an export acts on the document the reader has drawn, so it waits for the edits still on their way. With nothing on its way, the gesture runs at once. The - signature is a :data:`Wait`, so the wait can lead a chain of gates. + signature is a :data:`Wait`, which :func:`waiting` turns into the gate that leads a chain. """ self._rewrites.request(AfterEdits(gesture)) diff --git a/src/sampletones_application/coordinators/tabs/sequencer/voices.py b/src/sampletones_application/coordinators/tabs/sequencer/voices.py index debbe6e06..60b3eddf4 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/voices.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/voices.py @@ -31,8 +31,8 @@ class SequencerVoices: pool back to where it stood. A gesture reaching a file reads it before the pool is touched, which leaves a file the reader cannot use with the project and the history as they were. - A voice enters the pool of an open project alone. Each way in asks for one as it starts, so the - rule holds wherever the gesture came from: a button, a menu, or a key bound to it. + A voice enters the pool while a project is open. Each way in asks for the open project as it + starts, so the rule holds wherever the gesture came from: a button, a menu, or a key bound to it. """ def __init__( diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 42bdac59c..b6b2b9e7a 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -89,9 +89,11 @@ class SequencerHistoryDetail: read as two-digit hex; channels use the ``P``/``p``/``T``/``N`` abbreviations, concatenated when a sample-column gesture spans several channels. A gesture on the voice pool names its voice in the color of the kind that voice is, so a recording - and a hand-written one read apart down the list of entries. Every pool line about a - voice the pool holds names it the same way, by its position and by its name, so a - reader finds the voice in the list whichever gesture the line records. + and a hand-written one read apart down the list of entries. Every line about a gesture + on the pool itself, whether it adds, removes, replaces, renames, moves or duplicates a + voice, names the voice the same way, by its position and by its name, so a reader finds + it in the list whichever gesture the line records. A line about an edit inside a voice + names the voice by its position, beside what the edit touched. """ def __init__( diff --git a/src/sampletones_application/utils/callbacks/gates.py b/src/sampletones_application/utils/callbacks/gates.py index 36b5dd5bc..a8e6c9ddb 100644 --- a/src/sampletones_application/utils/callbacks/gates.py +++ b/src/sampletones_application/utils/callbacks/gates.py @@ -11,7 +11,7 @@ def ignore() -> None: - """Answers a request turned away that nothing waits on, so the request ends where it stopped.""" + """Answers a request turned away whose asker carries on as it stands, so the request ends where it stopped.""" def waiting(wait: Wait) -> Gate: diff --git a/src/sampletones_application/utils/gui/render_thread.py b/src/sampletones_application/utils/gui/render_thread.py index 7c7b14aa0..2b99fe208 100644 --- a/src/sampletones_application/utils/gui/render_thread.py +++ b/src/sampletones_application/utils/gui/render_thread.py @@ -14,7 +14,7 @@ FRAME_PAUSE: Final[float] = 1 / 60 -class RenderPhase(Enum): +class RenderThreadPhase(Enum): """Where a run stands with DearPyGui's context, which decides the thread work runs on.""" BUILDING = auto() @@ -22,21 +22,21 @@ class RenderPhase(Enum): STOPPED = auto() -_PHASE: RenderPhase = RenderPhase.BUILDING +_PHASE: RenderThreadPhase = RenderThreadPhase.BUILDING _RENDER_THREAD: Optional[int] = None def reset_render_thread() -> None: """Hands a fresh context to whichever thread asks, the state a new interface is built in.""" global _PHASE, _RENDER_THREAD # pylint: disable=global-statement - _PHASE = RenderPhase.BUILDING + _PHASE = RenderThreadPhase.BUILDING _RENDER_THREAD = None def claim_render_thread() -> None: """Names the thread DearPyGui's context belongs to, which is the one drawing the frames.""" global _PHASE, _RENDER_THREAD # pylint: disable=global-statement - _PHASE = RenderPhase.DRAWING + _PHASE = RenderThreadPhase.DRAWING _RENDER_THREAD = threading.get_ident() @@ -44,12 +44,12 @@ def release_render_thread() -> None: """Marks the loop as stopped, which a run does as its teardown begins. The thread that ran the loop keeps the context until it is destroyed, since the teardown runs - there. Work from any other thread joins the queue, which the teardown stops, so the queue lets it go. + there. Work from any other thread joins the queue, which nothing drains once the loop has stopped. A run taken down before its loop claimed a thread leaves the context to the thread taking it down. """ global _PHASE, _RENDER_THREAD # pylint: disable=global-statement - _PHASE = RenderPhase.STOPPED + _PHASE = RenderThreadPhase.STOPPED if _RENDER_THREAD is None: _RENDER_THREAD = threading.get_ident() @@ -62,9 +62,9 @@ def is_render_thread() -> bool: as long as the context lasts: while the loop draws, and while the run is taken down after it. """ match _PHASE: - case RenderPhase.BUILDING: + case RenderThreadPhase.BUILDING: return True - case RenderPhase.DRAWING | RenderPhase.STOPPED: + case RenderThreadPhase.DRAWING | RenderThreadPhase.STOPPED: return threading.get_ident() == _RENDER_THREAD From 8b7847480833043dfcb6bd3ebc2b9ec957060756 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 21:21:27 +0200 Subject: [PATCH 31/84] Tightened: the leaving and kept-size cases to the buffer and the window's place --- tests/screens/interface/display/steps.py | 6 ++++++ .../display/test_kept_size_at_close.py | 18 ++++++++++++++---- .../sampletones_application/test_startup.py | 4 +++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/screens/interface/display/steps.py b/tests/screens/interface/display/steps.py index d0836a19b..85581833c 100644 --- a/tests/screens/interface/display/steps.py +++ b/tests/screens/interface/display/steps.py @@ -19,6 +19,12 @@ def window_size(screen: Screen) -> Tuple[int, int]: return round(viewport.width), round(viewport.height) +def window_position(screen: Screen) -> Tuple[int, int]: + """Where the window's client area stands on the screen, left then top.""" + viewport = screen.bridge.ask(read_viewport) + return round(viewport.x), round(viewport.y) + + def framed(screen: Screen) -> bool: """Whether the window has its frame.""" return screen.bridge.ask(read_viewport_decorated) diff --git a/tests/screens/interface/display/test_kept_size_at_close.py b/tests/screens/interface/display/test_kept_size_at_close.py index 067436f99..1caafb49a 100644 --- a/tests/screens/interface/display/test_kept_size_at_close.py +++ b/tests/screens/interface/display/test_kept_size_at_close.py @@ -1,25 +1,34 @@ from typing import List, Tuple -from tests.screens.interface.display.steps import another_size, kept, open_display_settings, size_named, window_size +from tests.screens.interface.display.steps import ( + another_size, + kept, + open_display_settings, + size_named, + window_position, + 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. + writes the size and the place 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 + The session keeps them, 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 and place in the written session. """ def test_leaving_writes_the_confirmed_size(self, screen: Screen) -> None: settings = screen.display_settings opened: List[Tuple[int, int]] = [] + placed: List[Tuple[int, int]] = [] def keep_a_size_without_confirming_it(screen: Screen) -> None: opened.append(window_size(screen)) + placed.append(window_position(screen)) open_display_settings(screen) label = another_size(screen) @@ -38,5 +47,6 @@ def close_the_window(screen: Screen) -> None: assert screen.wait_for_exit() viewport = written_state().viewport assert (viewport.width, viewport.height) == opened[0] + assert (viewport.x, viewport.y) == placed[0] screen.scenario(keep_a_size_without_confirming_it, close_the_window).run() diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 6fa9fab3c..94620d37a 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -103,6 +103,7 @@ } CHOSEN_SAMPLE_RATE: Final[SampleRate] = 48000 CHOSEN_BUFFER_SIZE: Final[BufferSize] = 512 +APPLIED_BUFFER_SIZE: Final[BufferSize] = 2048 _DPG_DISPLAY_FUNCTIONS = [ "create_context", @@ -311,7 +312,7 @@ def test_a_committed_device_is_what_the_session_keeps(self, tmp_path: Path) -> N application._apply_audio_settings( int(SPEAKERS["index"]), CHOSEN_SAMPLE_RATE, - CHOSEN_BUFFER_SIZE, + APPLIED_BUFFER_SIZE, ) committed = application.audio_device_manager.get_current_device() self._leave(application) @@ -319,6 +320,7 @@ def test_a_committed_device_is_what_the_session_keeps(self, tmp_path: Path) -> N assert committed is not None assert committed.sample_rate == CHOSEN_SAMPLE_RATE assert self._remembered_device(tmp_path) == committed + assert ApplicationConfigManager(_profile(tmp_path).config).current_buffer_size == APPLIED_BUFFER_SIZE def test_a_failing_step_leaves_the_later_ones_taken(self, tmp_path: Path) -> None: with ExitStack() as stack: From 4017e9be639b896edf62a347af25c0cf1fefc74c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 21:21:46 +0200 Subject: [PATCH 32/84] Recorded: a rerun skipping a recording whose channels changed inside the run's folder --- docs/development/bugs-and-todos.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 58eb8c6fe..5760e0a88 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -153,3 +153,8 @@ currently out of line. An entry leaves when the code meets the contract again. nothing can play. * Apply in Audio settings on a machine that offers no output device logs a `KeyError` and leaves the window open: the window looks the empty device choice up among the devices it lists. +* A rerun over a folder skips a recording whose channels changed while the run's channels together stayed + the same: a run writes every recording into the one folder named after all its channels, and the rerun + skips each recording whose reconstruction stands there, whatever channels that file was made with. + Unticking Triangle on one of two recordings that both had Pulse 1 and Triangle, then running again, + reports that every recording is reconstructed already. From 75cb3ecd4e0894c35687c902bbe25ad83e9ef6b9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 21:49:15 +0200 Subject: [PATCH 33/84] Fixed: the screen tier reading a double-click before its witness counted it --- tests/suite/screens/dearpygui/gestures/pointer.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/suite/screens/dearpygui/gestures/pointer.py b/tests/suite/screens/dearpygui/gestures/pointer.py index c77cb2502..9d1d5e509 100644 --- a/tests/suite/screens/dearpygui/gestures/pointer.py +++ b/tests/suite/screens/dearpygui/gestures/pointer.py @@ -4,7 +4,7 @@ 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 IMGUI_DOUBLE_CLICK_SECONDS, Arrival +from tests.suite.screens.dearpygui.gestures.arrival import ARRIVAL_FRAMES, IMGUI_DOUBLE_CLICK_SECONDS, Arrival from tests.suite.screens.dearpygui.gestures.constants import ( HOLD_FRAMES, HOVER_FRAMES, @@ -209,13 +209,18 @@ def _require_double_click( """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. + more slowly than Dear ImGui's double-click time allows. The witness counts a double-click among + the callbacks the frame holds, which can run after the button already reads down, so the count + is read once a frame for a few frames. 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 + for _ in range(ARRIVAL_FRAMES): + if self._bridge.ask(lambda: self._witness.double_clicks(imgui_button)) > double_clicks: + return + + self._settle(ONE_FRAME) 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" From 20f9b7c7666038d0ab778f9b2f4a186f30e19d56 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 3 Oct 2026 22:58:14 +0200 Subject: [PATCH 34/84] Fixed: the screen tier's silent device playing through the sound server here and racing through its sound on CI --- docs/development/application/screens.md | 5 +- tests/suite/screens/boundaries/audio.py | 91 ++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index 8f2c66b46..616919eac 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -180,7 +180,10 @@ how a menu popup's width is measured. 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. + heard. It is the one device the application finds on every machine: the scenario's ALSA reads a + configuration of its own, which keeps the machine's sound cards and sound server outside the run, and a + clock returns each write and each stop when a device would. 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, and so do the stand-ins below. diff --git a/tests/suite/screens/boundaries/audio.py b/tests/suite/screens/boundaries/audio.py index 68273d9a9..3550e12eb 100644 --- a/tests/suite/screens/boundaries/audio.py +++ b/tests/suite/screens/boundaries/audio.py @@ -1,13 +1,17 @@ import threading +import time from enum import StrEnum from pathlib import Path from typing import Final, Optional +from weakref import WeakKeyDictionary import numpy as np import pyaudio import pytest -ALSA_CONFIGURATION_FILE: Final[str] = ".asoundrc" +ALSA_CONFIGURATION_FILE: Final[str] = ".asound.conf" +ALSA_CONFIGURATION_VARIABLE: Final[str] = "ALSA_CONFIG_PATH" +DEVICE_BUFFER_SECONDS: Final[float] = 0.05 SILENT_DEFAULT_DEVICE: Final[str] = "pcm.!default {\n type null\n}\n" @@ -59,6 +63,81 @@ def _count(self, frames: bytes) -> None: self._sounding_samples += sounding +class DeviceClock: + """Plays a stream's sound out at its rate: a write returns once what it hands over fits the buffer, and a + stop once the buffer has played out. + + The buffer keeps ``DEVICE_BUFFER_SECONDS`` of sound waiting, as a device's does. Sound written once the + buffer has run dry starts playing as it arrives, as it does on a device that underran. + """ + + def __init__(self, bytes_per_second: int) -> None: + self._bytes_per_second = bytes_per_second + self._played_out_at = time.monotonic() + + def write(self, frames: bytes) -> None: + """Returns once ``frames`` fit in the buffer, the moment a device's blocking write returns.""" + now = time.monotonic() + self._played_out_at = max(self._played_out_at, now) + len(frames) / self._bytes_per_second + time.sleep(max(0.0, self._played_out_at - DEVICE_BUFFER_SECONDS - now)) + + def drain(self) -> None: + """Returns once the sound written has played out, the moment a device's stop returns.""" + time.sleep(max(0.0, self._played_out_at - time.monotonic())) + + +class SilentOutputDevice: + """The one output device a scenario's application finds: ALSA's null sink, played out in real time. + + The scenario's ALSA reads a configuration of its own, which names the null sink its default device and + leaves the machine's sound cards and sound server out, so every machine offers the same device and plays + into silence. The null sink takes sound as fast as it comes, so a clock behind each output stream returns + a write and a stop when a device would. + """ + + def __init__(self) -> None: + self._clocks: WeakKeyDictionary[pyaudio.PyAudio.Stream, DeviceClock] = WeakKeyDictionary() + + def install(self, home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Points ALSA at the null sink and puts a clock behind every stream before the application starts.""" + configuration = home / ALSA_CONFIGURATION_FILE + configuration.write_text(SILENT_DEFAULT_DEVICE, encoding="utf-8") + monkeypatch.setenv(ALSA_CONFIGURATION_VARIABLE, str(configuration)) + + open_stream = pyaudio.PyAudio.open + write = pyaudio.PyAudio.Stream.write + stop_stream = pyaudio.PyAudio.Stream.stop_stream + + def opened( + audio: pyaudio.PyAudio, + *, + rate: int, + channels: int, + format: int, # pylint: disable=redefined-builtin + **options: object, + ) -> pyaudio.PyAudio.Stream: + stream = open_stream(audio, rate=rate, channels=channels, format=format, **options) + self._clocks[stream] = DeviceClock(rate * channels * pyaudio.get_sample_size(format)) + return stream + + def played( + stream: pyaudio.PyAudio.Stream, + frames: bytes, + num_frames: Optional[int] = None, + exception_on_underflow: bool = False, + ) -> None: + write(stream, frames, num_frames, exception_on_underflow) + self._clocks[stream].write(frames) + + def stopped(stream: pyaudio.PyAudio.Stream) -> None: + self._clocks[stream].drain() + stop_stream(stream) + + monkeypatch.setattr(pyaudio.PyAudio, "open", opened) + monkeypatch.setattr(pyaudio.PyAudio.Stream, "write", played) + monkeypatch.setattr(pyaudio.PyAudio.Stream, "stop_stream", stopped) + + def provide_output_device( device: OutputDevice, *, @@ -68,19 +147,11 @@ def provide_output_device( """Gives the application the output ``device`` before it starts.""" match device: case OutputDevice.SILENT: - _silent_output_device(home) + SilentOutputDevice().install(home, monkeypatch) 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) From 526f16bfc2d3780aea1eb770b9224fb216d64448 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 10:18:38 +0200 Subject: [PATCH 35/84] Fixed: a folder read failing on a folder it may list but not enter, and saying nothing plain when the folder itself is closed --- .../coordinators/tabs/main.py | 4 +- src/sampletones_config/lang/en.yaml | 1 + .../reconstructions/converter/paths/utils.py | 9 ++++- src/sampletones_shared/paths/extensions.py | 9 ++++- tests/suite/files.py | 29 ++++++++++++++ .../coordinators/tabs/test_main.py | 15 ++++++++ .../services/test_folder_scan.py | 38 +++++++++++++++++++ .../converter/paths/test_utils.py | 25 ++++++++++++ .../paths/test_extensions.py | 35 +++++++++++++++++ 9 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 tests/unit/sampletones_shared/paths/test_extensions.py diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index daf69af50..8e398ed60 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -519,9 +519,9 @@ def _on_directory_add_requested(self, directory_path: Path) -> None: self._folder_scan.start(directory_path, self._gather_read) def _on_scan_failed(self, exception: Exception) -> None: - """Takes the wait away and shows the failure that ended the reading.""" + """Takes the wait away and shows the failure that ended the reading, which added nothing.""" self._scan_window.close() - self._dialogs.show_error(exception) + self._dialogs.show_error(exception, self._language_manager["main.converter.message.scan_failed"]) def _gather_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: """Gathers what the walk found, saying so where the folder holds no recordings.""" diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index dd6242987..e7a8d8390 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -413,6 +413,7 @@ main.converter.title.scan_dialog: "Reading the folder" main.converter.template.scan_progress: "Found {count} recordings in {name}" main.converter.message.scan_opening: "Looking through {name}..." main.converter.message.scan_nothing_below: "There are no recordings in this folder." +main.converter.message.scan_failed: "Can't read this folder, so no recordings were added." main.converter.label.stop_scan_button: "Stop" main.converter.title.progress_dialog: "Reconstruction progress" main.converter.title.load_dialog: "Reconstruction complete" diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index a2769de5d..35b03f7f5 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from typing import AbstractSet, Iterator, List, Tuple @@ -105,8 +106,14 @@ def walk_entries(input_directory: Path) -> Iterator[Path]: """Every path below a directory, reported as the walk meets it. A caller that has to answer between entries — one counting what it has found, or one a reader - may stop partway — reads the tree through this and decides for itself what each entry is. + may stop partway — reads the tree through this and decides for itself what each entry is. A + folder below the directory that the reader may not open is passed over with everything it + holds, so one locked folder leaves the rest of the tree to the walk. + + Raises: + OSError: If the directory itself cannot be opened, which leaves the walk nothing to read. """ + os.scandir(input_directory).close() return input_directory.rglob("*") diff --git a/src/sampletones_shared/paths/extensions.py b/src/sampletones_shared/paths/extensions.py index d696b22f1..ca597d69d 100644 --- a/src/sampletones_shared/paths/extensions.py +++ b/src/sampletones_shared/paths/extensions.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from typing import Final, Tuple @@ -29,5 +30,9 @@ def is_audio_file(path: Path, extensions: Tuple[str, ...] = EXT_FILES_AUDIO) -> bool: - """Whether a path names a recording a run converts.""" - return path.is_file() and path.suffix.lower() in extensions + """Whether a path names a recording a run converts. + + A recording is a file the reader may inspect, so an entry of a folder that lists its names and + keeps the rest to itself reads as none. + """ + return path.suffix.lower() in extensions and os.path.isfile(path) diff --git a/tests/suite/files.py b/tests/suite/files.py index e90e20d9c..93c59b241 100644 --- a/tests/suite/files.py +++ b/tests/suite/files.py @@ -1,4 +1,20 @@ +import os +import sys +from contextlib import contextmanager from pathlib import Path +from typing import Final, Iterator + +import pytest + +OPEN_FOLDER: Final[int] = 0o755 +LOCKED_FOLDER: Final[int] = 0o000 +NAMES_ONLY_FOLDER: Final[int] = 0o444 +FOLDER_PERMISSIONS_HOLD: Final[bool] = sys.platform != "win32" and os.geteuid() != 0 + +requires_folder_permissions = pytest.mark.skipif( + not FOLDER_PERMISSIONS_HOLD, + reason="a folder's permissions lock it on a POSIX system for a reader other than root", +) def empty_file(directory: Path, name: str) -> Path: @@ -7,3 +23,16 @@ def empty_file(directory: Path, name: str) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b"") return path + + +@contextmanager +def held_at(folder: Path, mode: int) -> Iterator[Path]: + """Holds ``folder`` at the permissions ``mode`` while the block runs, and opens it again after. + + The folder opens again however the block ends, so the case's temporary tree can be cleared. + """ + folder.chmod(mode) + try: + yield folder + finally: + folder.chmod(OPEN_FOLDER) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index d7142d7ea..c8c4252e0 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -21,6 +21,7 @@ ) from sampletones_core.constants.enums import ChannelName from tests.suite.application import settled +from tests.suite.files import LOCKED_FOLDER, held_at, requires_folder_permissions from tests.suite.language import FakeLanguageManager CONVERTER_RUNNING_MESSAGE_KEY: Final[str] = "main.explorer.message.converter_running_msg" @@ -205,6 +206,7 @@ def test_cancel_request_confirms_before_stopping(self) -> None: DISCARD_STEMS_PROMPT_KEY: Final[str] = "main.converter.message.discard_stems_prompt" +SCAN_FAILED_KEY: Final[str] = "main.converter.message.scan_failed" DISCARD_STEMS_BUTTON_KEY: Final[str] = "main.converter.label.discard_stems_button" KEEP_STEMS_BUTTON_KEY: Final[str] = "main.converter.label.keep_stems_button" @@ -336,6 +338,19 @@ def test_a_folder_holding_no_recordings_says_so(self, tmp_path: Path) -> None: coordinator._converter_logic.gather_folder.assert_not_called() assert coordinator._dialogs.show_info.call_args.args[1] == NOTHING_BELOW_KEY + @requires_folder_permissions + def test_a_folder_it_may_not_open_says_so_and_adds_nothing(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=False) + root = _folder_of(tmp_path, 2) + + with held_at(root, LOCKED_FOLDER): + _add_folder(coordinator, root) + + coordinator._converter_logic.gather_folder.assert_not_called() + failure, message = coordinator._dialogs.show_error.call_args.args + assert isinstance(failure, PermissionError) + assert message == SCAN_FAILED_KEY + class TestFileAdd: """A recording added from the browser's menu joins the setup, whichever run it names.""" diff --git a/tests/unit/sampletones_application/services/test_folder_scan.py b/tests/unit/sampletones_application/services/test_folder_scan.py index 5ece31758..39724b0a3 100644 --- a/tests/unit/sampletones_application/services/test_folder_scan.py +++ b/tests/unit/sampletones_application/services/test_folder_scan.py @@ -15,6 +15,12 @@ ) from sampletones_application.services.folder_scan.service import REPORT_EVERY, FolderScanService from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from tests.suite.files import ( + LOCKED_FOLDER, + NAMES_ONLY_FOLDER, + held_at, + requires_folder_permissions, +) PRIORITY = 0 @@ -101,6 +107,23 @@ def test_a_folder_holding_none_answers_with_none( assert found(reports)[0].recordings == () + @requires_folder_permissions + def test_the_folders_it_may_not_read_are_passed_over( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + """A locked folder and one listing names only keep their recordings; the rest are found.""" + root = tree(tmp_path / "takes", 2, deep=1) + locked = tree(root / "locked", 3) + names_only = tree(root / "names_only", 3) + + with held_at(locked, LOCKED_FOLDER), held_at(names_only, NAMES_ONLY_FOLDER): + service.start(FolderScanRequest(root=root)) + + assert len(found(reports)[0].recordings) == 3 + class TestWhatTheReaderIsTold: """The reader hears that the reading began and how far it has got.""" @@ -175,6 +198,21 @@ def raising(_root: Path) -> Iterator[Path]: assert isinstance(reports[-1], FolderScanError) assert reports[-1].exception is failure + @requires_folder_permissions + def test_a_folder_it_may_not_open_reports_its_failure( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + root = tree(tmp_path / "takes", 2) + + with held_at(root, LOCKED_FOLDER): + service.start(FolderScanRequest(root=root)) + + assert isinstance(reports[-1], FolderScanError) + assert isinstance(reports[-1].exception, PermissionError) + class TestShuttingDown: """A walk ends with the run: a shutdown is heard at the next entry, and nothing reports after it.""" diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py index ad341fb65..44a84814c 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py @@ -11,8 +11,10 @@ get_output_path, get_relative_path, group_output_path, + walk_entries, ) from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +from tests.suite.files import LOCKED_FOLDER, held_at, requires_folder_permissions CHANNELS = frozenset(DEFAULT_CHANNELS) @@ -104,6 +106,29 @@ def test_sorted_when_sort_is_true(self, tmp_path: Path) -> None: assert names == sorted(names) +@requires_folder_permissions +class TestWalkEntries: + """A walk reads every folder it may open and passes over the ones it may not.""" + + def test_a_locked_folder_is_passed_over_with_what_it_holds(self, tmp_path: Path) -> None: + (tmp_path / "open").mkdir() + (tmp_path / "open" / "kept.wav").touch() + locked = tmp_path / "locked" + (locked / "deeper").mkdir(parents=True) + (locked / "hidden.wav").touch() + + with held_at(locked, LOCKED_FOLDER): + names = sorted(path.name for path in walk_entries(tmp_path)) + + assert names == ["kept.wav", "locked", "open"] + + def test_a_folder_that_cannot_be_opened_raises(self, tmp_path: Path) -> None: + (tmp_path / "take.wav").touch() + + with held_at(tmp_path, LOCKED_FOLDER), pytest.raises(PermissionError): + walk_entries(tmp_path) + + class TestFilterFiles: def test_includes_files_without_existing_output(self, tmp_path: Path) -> None: audio_files: List[Path] = [tmp_path / "song.wav"] diff --git a/tests/unit/sampletones_shared/paths/test_extensions.py b/tests/unit/sampletones_shared/paths/test_extensions.py new file mode 100644 index 000000000..faf98df45 --- /dev/null +++ b/tests/unit/sampletones_shared/paths/test_extensions.py @@ -0,0 +1,35 @@ +from pathlib import Path + +from sampletones_shared.paths.extensions import is_audio_file +from tests.suite.files import NAMES_ONLY_FOLDER, held_at, requires_folder_permissions + + +class TestIsAudioFile: + """A recording is a file with a recording's extension that the reader may inspect.""" + + def test_a_recording_reads_as_one(self, tmp_path: Path) -> None: + recording = tmp_path / "take.WAV" + recording.touch() + + assert is_audio_file(recording) + + def test_another_kind_of_file_reads_as_none(self, tmp_path: Path) -> None: + notes = tmp_path / "notes.txt" + notes.touch() + + assert not is_audio_file(notes) + + def test_a_folder_named_like_a_recording_reads_as_none(self, tmp_path: Path) -> None: + folder = tmp_path / "takes.wav" + folder.mkdir() + + assert not is_audio_file(folder) + + @requires_folder_permissions + def test_a_recording_in_a_folder_listing_names_only_reads_as_none(self, tmp_path: Path) -> None: + folder = tmp_path / "names_only" + folder.mkdir() + (folder / "take.wav").touch() + + with held_at(folder, NAMES_ONLY_FOLDER): + assert not is_audio_file(folder / "take.wav") From 5da00bde60637f9a8ff93c39dc19f51369929b51 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 10:23:29 +0200 Subject: [PATCH 36/84] Fixed: a rerun skipping a recording whose channels changed, by writing each reconstruction into the folder of its own channels --- docs/development/bugs-and-todos.md | 5 - docs/guide/converting.md | 4 +- .../logic/main/converter/destination.py | 2 +- .../converter/paths/__init__.py | 2 + .../reconstructions/converter/paths/fields.py | 2 +- .../reconstructions/converter/paths/utils.py | 13 ++- .../reconstructions/converter/plan/batch.py | 64 +++++------ tests/screens/main/run/test_destination.py | 9 +- .../logic/main/converter/test_destination.py | 4 +- .../converter/plan/test_plans.py | 108 +++++++++++------- 10 files changed, 121 insertions(+), 92 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 5760e0a88..58eb8c6fe 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -153,8 +153,3 @@ currently out of line. An entry leaves when the code meets the contract again. nothing can play. * Apply in Audio settings on a machine that offers no output device logs a `KeyError` and leaves the window open: the window looks the empty device choice up among the devices it lists. -* A rerun over a folder skips a recording whose channels changed while the run's channels together stayed - the same: a run writes every recording into the one folder named after all its channels, and the rerun - skips each recording whose reconstruction stands there, whatever channels that file was made with. - Unticking Triangle on one of two recordings that both had Pulse 1 and Triangle, then running again, - reports that every recording is reconstructed already. diff --git a/docs/guide/converting.md b/docs/guide/converting.md index fca5ce4d4..74066443e 100644 --- a/docs/guide/converting.md +++ b/docs/guide/converting.md @@ -93,7 +93,9 @@ Click the button under **Output** to start the conversion. Its label says what i reads **Cancel** while the conversion runs. Only one conversion runs at a time. **Destination:** shows where the result is saved: a single file for one conversion, or a folder for a -longer run. Click the path to open it in your file manager. If the conversion would replace an +longer run. Each reconstruction goes into a folder named after the channels its recording uses, so +recordings with different channels are saved in different folders, and **Destination** shows the folder +that holds them all. Click the path to open it in your file manager. If the conversion would replace an existing reconstruction, the app asks you first. When the conversion finishes, click **Load** to open the result on the **Reconstruction** tab, where diff --git a/src/sampletones_application/logic/main/converter/destination.py b/src/sampletones_application/logic/main/converter/destination.py index 497fa9dc3..2184e6b17 100644 --- a/src/sampletones_application/logic/main/converter/destination.py +++ b/src/sampletones_application/logic/main/converter/destination.py @@ -36,7 +36,7 @@ def aimed_at(self, config: Config, plan: Optional[ConversionPlan]) -> Self: """The destination the plan a run amounts to names. A run of one names the document it writes, which is what a reader converting a single file - is looking at; a larger one names the folder every reconstruction it writes lands in. A run + is looking at; a larger one names the folder holding every reconstruction it writes. A run with nobody taking part names nothing of its own, so the destination it last held stands until a recording joins it. """ diff --git a/src/sampletones_core/reconstructions/converter/paths/__init__.py b/src/sampletones_core/reconstructions/converter/paths/__init__.py index f52f1c441..acb80aa35 100644 --- a/src/sampletones_core/reconstructions/converter/paths/__init__.py +++ b/src/sampletones_core/reconstructions/converter/paths/__init__.py @@ -9,6 +9,7 @@ get_relative_path, group_output_path, named_output_path, + reconstructions_directory, walk_audio_files, walk_entries, ) @@ -22,6 +23,7 @@ "get_relative_path", "group_output_path", "named_output_path", + "reconstructions_directory", "walk_audio_files", "walk_entries", ] diff --git a/src/sampletones_core/reconstructions/converter/paths/fields.py b/src/sampletones_core/reconstructions/converter/paths/fields.py index e238835ea..a7974db2a 100644 --- a/src/sampletones_core/reconstructions/converter/paths/fields.py +++ b/src/sampletones_core/reconstructions/converter/paths/fields.py @@ -47,7 +47,7 @@ def channels(self) -> Tuple[ChannelName, ...]: @classmethod def from_config(cls, config: Config, channels: AbstractSet[ChannelName]) -> Self: - """The fields a run's own directory is named from: its settings, and the channels it hands out. + """The fields a reconstruction's directory is named from: the settings, and the channels it was handed. The channels come from the setup rather than the configuration, so the name is written in the order the application states them however the caller gathered the set. diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index 35b03f7f5..bcb95b738 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -28,17 +28,22 @@ def get_relative_path( return Path(output_path.absolute()) +def reconstructions_directory(config: Config) -> Path: + """The directory holding the folder of every setting a run writes under.""" + return to_path(config.general.reconstructions_directory) + + def config_directory_path( config: Config, channels: AbstractSet[ChannelName], ) -> Path: - """The directory a run writes its reconstructions into. + """The directory a reconstruction is written into. - The directory is named after the settings that shaped the library and the channels the run - hands out, so runs that differ in either keep their results apart. + The directory is named after the settings that shaped the library and the channels the + reconstruction was handed, so reconstructions that differ in either keep apart. """ config_directory = ConfigDirectoryFields.generate_config_directory_name(config, channels) - return to_path(config.general.reconstructions_directory) / config_directory + return reconstructions_directory(config) / config_directory def get_output_path( diff --git a/src/sampletones_core/reconstructions/converter/plan/batch.py b/src/sampletones_core/reconstructions/converter/plan/batch.py index 464bae0e7..5e9b99ddd 100644 --- a/src/sampletones_core/reconstructions/converter/plan/batch.py +++ b/src/sampletones_core/reconstructions/converter/plan/batch.py @@ -1,14 +1,14 @@ from dataclasses import dataclass from pathlib import Path -from typing import FrozenSet, List, Optional, Tuple +from typing import List, Optional, Tuple from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions.converter.job import ConversionJob from sampletones_core.reconstructions.converter.paths.utils import ( config_directory_path, get_relative_path, named_output_path, + reconstructions_directory, ) from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_shared.exceptions import NoFilesToProcessError @@ -18,9 +18,11 @@ class BatchEntry: """One recording a batch converts on its own, under the setup handing out its channels. - ``base_directory`` names the folder the recording was gathered from, whose tree the written - reconstructions mirror. A recording gathered by name carries none, and its reconstruction - sits directly in the directory its batch writes into. + The reconstruction lands in the folder named after the channels this recording is handed, so + a folder holds reconstructions made with the channels its name states. ``base_directory`` + names the folder the recording was gathered from, whose tree the written reconstructions + mirror. A recording gathered by name carries none, and its reconstruction sits directly in its + channels' folder. """ source: Path @@ -32,8 +34,13 @@ def is_named(self) -> bool: """The reader named this recording itself, rather than the folder holding it.""" return self.base_directory is None - def output_path(self, directory: Path) -> Path: - """The reconstruction this recording is written to, inside the directory its batch writes into.""" + def directory(self, config: Config) -> Path: + """The folder of the channels this recording is handed, which its reconstruction lands in.""" + return config_directory_path(config, self.stems.covered_channels) + + def output_path(self, config: Config) -> Path: + """The reconstruction this recording is written to.""" + directory = self.directory(config) if self.base_directory is None: return named_output_path(directory, (self.source,)) @@ -44,35 +51,30 @@ def output_path(self, directory: Path) -> Path: class BatchConversion: """One reconstruction per recording gathered, each built from that recording alone. - Every recording carries the channels its own row holds, so one batch writes as many setups - as the reader worked out. They all land in one directory, named after every channel the - batch hands out, so the run fills one folder. A recording named by the reader is written - whenever the batch runs; one gathered from a folder is left as it stands where its - reconstruction is already written, so a repeated run over a folder picks up where the last - one stopped. + Every recording carries the channels its own row holds and is written into the folder named + after them, so one batch writes as many folders as the setups the reader worked out. A + recording named by the reader is written whenever the batch runs; one gathered from a folder is + left as it stands where its own reconstruction is already written, so a repeated run over a + folder picks up where the last one stopped and writes again the recordings whose channels + changed since. """ entries: Tuple[BatchEntry, ...] - @property - def covered_channels(self) -> FrozenSet[ChannelName]: - """Every channel the batch hands out between its recordings.""" - return frozenset().union(*(entry.stems.covered_channels for entry in self.entries)) - - def directory(self, config: Config) -> Path: - """The directory every recording of this batch is written into. + def destination(self, config: Config) -> Path: + """The reconstruction a batch of one writes, or the folder holding everything a larger batch writes. - Raises: - pydantic.ValidationError: If the batch holds no recording, which hands out no channel. + Recordings sharing their channels share one folder, which is that folder; recordings with + different channels fill one folder apiece, all held in the reconstructions directory. """ - return config_directory_path(config, self.covered_channels) - - def destination(self, config: Config) -> Path: - """The reconstruction a batch of one writes, or the directory a larger batch writes into.""" if len(self.entries) == 1: - return self.entries[0].output_path(self.directory(config)) + return self.entries[0].output_path(config) - return self.directory(config) + directories = {entry.directory(config) for entry in self.entries} + if len(directories) == 1: + return directories.pop() + + return reconstructions_directory(config) def jobs(self, config: Config) -> List[ConversionJob]: """The single-source jobs this batch writes. @@ -97,8 +99,4 @@ def existing_targets(self, config: Config) -> Tuple[Path, ...]: ) def _targets(self, config: Config) -> List[Tuple[BatchEntry, Path]]: - if not self.entries: - return [] - - directory = self.directory(config) - return [(entry, entry.output_path(directory)) for entry in self.entries] + return [(entry, entry.output_path(config)) for entry in self.entries] diff --git a/tests/screens/main/run/test_destination.py b/tests/screens/main/run/test_destination.py index cb2cec685..c63baac49 100644 --- a/tests/screens/main/run/test_destination.py +++ b/tests/screens/main/run/test_destination.py @@ -39,8 +39,9 @@ def test_each_recording_carries_the_channels_its_row_gave(self, screen: Screen) for path, channel in self.channels().items(): assert converted_channels(written_as(path)) == {channel} - 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.""" + def test_the_destination_line_names_the_folder_holding_what_the_run_writes(self, screen: Screen) -> None: + """Each reconstruction lands in a folder of its own channels, and the Destination line names the + folder holding both.""" converter = screen.main.converter gather(screen, home_path(BASS), home_path(LEAD)) give_each_its_own_channel(screen, self.channels()) @@ -48,7 +49,9 @@ def test_the_destination_line_names_the_folder_the_run_writes_into(self, screen: run_to_its_end(screen) - assert all(written_as(path).parent == destination for path in self.channels()) + folders = {written_as(path).parent for path in self.channels()} + assert len(folders) == len(self.channels()) + assert all(folder.parent == destination for folder in folders) class TestAFolderConverts: diff --git a/tests/unit/sampletones_application/logic/main/converter/test_destination.py b/tests/unit/sampletones_application/logic/main/converter/test_destination.py index f5f148100..00662b69e 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_destination.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_destination.py @@ -54,7 +54,7 @@ def test_a_mix_names_the_file_its_plan_writes(self, tmp_path: Path) -> None: assert Destination.unset().aimed_at(config, plan).output_path == plan.destination(config) - def test_a_batch_names_the_folder_its_plan_writes_into(self, tmp_path: Path) -> None: + def test_a_batch_names_the_folder_holding_what_its_plan_writes(self, tmp_path: Path) -> None: config = Config() plan = BatchConversion( entries=( @@ -66,4 +66,4 @@ def test_a_batch_names_the_folder_its_plan_writes_into(self, tmp_path: Path) -> output_path = Destination.unset().aimed_at(config, plan).output_path assert output_path == plan.destination(config) - assert all(job.output_path.parent == output_path for job in plan.jobs(config)) + assert all(output_path in job.output_path.parents for job in plan.jobs(config)) diff --git a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py index a01e8f157..d7dff04a4 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py +++ b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py @@ -340,76 +340,90 @@ def test_a_directory_conversion_settles_the_question_itself( assert plan.existing_targets(config) == () -class TestOneFolderPerBatch: - """A batch writes every recording into one folder, named after every channel it hands out.""" +class TestOneFolderPerChannelSet: + """A batch writes each recording into the folder named after the channels that recording is handed.""" @pytest.fixture def pulse(self) -> StemsConfig: return StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1])) @pytest.fixture - def triangle(self) -> StemsConfig: - return StemsConfig.single_entry(StemSettings.covering([ChannelName.TRIANGLE])) + def pulse_and_triangle(self) -> StemsConfig: + return StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1, ChannelName.TRIANGLE])) - def test_recordings_with_different_channels_share_the_folder_of_both( + def test_each_recording_lands_in_the_folder_of_its_own_channels( self, config: Config, pulse: StemsConfig, - triangle: StemsConfig, + pulse_and_triangle: StemsConfig, tmp_path: Path, ) -> None: lead, bass = _write_audio_files(tmp_path, ["lead.wav", "bass.wav"]) plan = BatchConversion( entries=( - BatchEntry(source=lead, stems=pulse, base_directory=None), - BatchEntry(source=bass, stems=triangle, base_directory=None), + BatchEntry(source=lead, stems=pulse_and_triangle, base_directory=None), + BatchEntry(source=bass, stems=pulse, base_directory=None), + ) + ) + + assert [job.output_path.parent for job in plan.jobs(config)] == [ + config_directory_path(config, frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE})), + config_directory_path(config, frozenset({ChannelName.PULSE1})), + ] + + def test_a_folder_gathered_with_different_channels_mirrors_into_each_channels_folder( + self, + config: Config, + pulse: StemsConfig, + pulse_and_triangle: StemsConfig, + tmp_path: Path, + ) -> None: + root = tmp_path / "loops" + first, second = _write_audio_files(root, ["a.wav", "nested/b.wav"]) + plan = BatchConversion( + entries=( + BatchEntry(source=first, stems=pulse_and_triangle, base_directory=root), + BatchEntry(source=second, stems=pulse, base_directory=root), ) ) - folder = config_directory_path(config, frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE})) - assert [job.output_path.parent for job in plan.jobs(config)] == [folder, folder] - assert plan.destination(config) == folder + both = config_directory_path(config, frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE})) / root.name + alone = config_directory_path(config, frozenset({ChannelName.PULSE1})) / root.name + assert [job.output_path for job in plan.jobs(config)] == [both / "a.stn", alone / "nested" / "b.stn"] - def test_the_folder_names_no_channel_one_recording_alone_takes( + def test_recordings_sharing_their_channels_name_their_one_folder( self, config: Config, pulse: StemsConfig, - triangle: StemsConfig, tmp_path: Path, ) -> None: - """Neither recording's own folder is where the batch writes, since each names part of the run.""" lead, bass = _write_audio_files(tmp_path, ["lead.wav", "bass.wav"]) plan = BatchConversion( entries=( BatchEntry(source=lead, stems=pulse, base_directory=None), - BatchEntry(source=bass, stems=triangle, base_directory=None), + BatchEntry(source=bass, stems=pulse, base_directory=None), ) ) - own_folders = { - config_directory_path(config, frozenset({ChannelName.PULSE1})), - config_directory_path(config, frozenset({ChannelName.TRIANGLE})), - } - assert {job.output_path.parent for job in plan.jobs(config)}.isdisjoint(own_folders) + assert plan.destination(config) == config_directory_path(config, frozenset({ChannelName.PULSE1})) - def test_a_folder_gathered_with_different_channels_mirrors_into_the_one_folder( + def test_recordings_with_different_channels_name_the_folder_holding_theirs( self, - config: Config, pulse: StemsConfig, - triangle: StemsConfig, + pulse_and_triangle: StemsConfig, tmp_path: Path, ) -> None: - root = tmp_path / "loops" - first, second = _write_audio_files(root, ["a.wav", "nested/b.wav"]) + reconstructions = tmp_path / "out" + config = _config_writing_under(reconstructions) + lead, bass = _write_audio_files(tmp_path, ["lead.wav", "bass.wav"]) plan = BatchConversion( entries=( - BatchEntry(source=first, stems=pulse, base_directory=root), - BatchEntry(source=second, stems=triangle, base_directory=root), + BatchEntry(source=lead, stems=pulse_and_triangle, base_directory=None), + BatchEntry(source=bass, stems=pulse, base_directory=None), ) ) - mirrored = config_directory_path(config, frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE})) / root.name - assert [job.output_path for job in plan.jobs(config)] == [mirrored / "a.stn", mirrored / "nested" / "b.stn"] + assert plan.destination(config) == reconstructions def test_a_batch_of_one_names_the_file_it_writes( self, @@ -423,29 +437,39 @@ def test_a_batch_of_one_names_the_file_it_writes( assert plan.destination(config) == plan.jobs(config)[0].output_path assert plan.destination(config) == group_output_path(config, (source,), frozenset({ChannelName.PULSE1})) - def test_a_rerun_skips_what_stands_in_the_one_folder( + def test_a_rerun_writes_again_a_recording_whose_channels_changed( self, pulse: StemsConfig, - triangle: StemsConfig, + pulse_and_triangle: StemsConfig, tmp_path: Path, ) -> None: - """A recording written in the folder of its own channels alone is written again into the batch's.""" + """The first run wrote both with Pulse 1 and Triangle; the bass then lost Triangle.""" config = _config_writing_under(tmp_path / "out") root = tmp_path / "loops" - first, second = _write_audio_files(root, ["a.wav", "b.wav"]) - plan = BatchConversion( + lead, bass = _write_audio_files(root, ["lead.wav", "bass.wav"]) + first_run = BatchConversion( + entries=( + BatchEntry(source=lead, stems=pulse_and_triangle, base_directory=root), + BatchEntry(source=bass, stems=pulse_and_triangle, base_directory=root), + ) + ) + for job in first_run.jobs(config): + job.output_path.parent.mkdir(parents=True, exist_ok=True) + job.output_path.touch() + rerun = BatchConversion( entries=( - BatchEntry(source=first, stems=pulse, base_directory=root), - BatchEntry(source=second, stems=triangle, base_directory=root), + BatchEntry(source=lead, stems=pulse_and_triangle, base_directory=root), + BatchEntry(source=bass, stems=pulse, base_directory=root), ) ) - in_the_batch_folder = plan.jobs(config)[0].output_path - in_its_own_folder = config_directory_path(config, frozenset({ChannelName.TRIANGLE})) / root.name / "b.stn" - for written in (in_the_batch_folder, in_its_own_folder): - written.parent.mkdir(parents=True, exist_ok=True) - written.touch() - assert [job.sources[0] for job in plan.jobs(config)] == [second] + jobs = rerun.jobs(config) + + assert [job.sources[0] for job in jobs] == [bass] + assert ( + jobs[0].output_path + == config_directory_path(config, frozenset({ChannelName.PULSE1})) / root.name / "bass.stn" + ) class TestEveryJobWritesWhereItsPlanSays: From 21d6cd574af57035224f628ab09569c2c2084d87 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 10:35:30 +0200 Subject: [PATCH 37/84] Fixed: playing and applying Audio settings on a machine with no audio output failing without a word --- docs/development/application/playback.md | 2 + docs/development/bugs-and-todos.md | 6 - src/sampletones_application/application.py | 13 +- .../categories/elements/global_.py | 2 + .../coordinators/playback/failures.py | 49 +++++++ .../coordinators/playback/guard.py | 12 +- .../coordinators/tabs/instructions.py | 4 +- .../coordinators/tabs/main.py | 5 +- .../coordinators/tabs/reconstruction.py | 7 +- .../tabs/sequencer/coordinator.py | 26 +++- .../logic/reconstruction/audition.py | 14 +- .../logic/sequencer/playback/song_player.py | 10 +- .../logic/sequencer/voices.py | 7 +- .../logic/shared/audio_player.py | 22 ++- .../logic/shared/file_playback.py | 14 +- .../services/song_player/service.py | 11 +- src/sampletones_application/tags/general.py | 6 + .../ui/panels/dialogs/audio_settings.py | 9 +- src/sampletones_config/lang/en.yaml | 2 + src/sampletones_core/audio/manager.py | 61 ++++++-- src/sampletones_shared/exceptions/__init__.py | 8 +- src/sampletones_shared/exceptions/audio.py | 4 + .../screens/application/no_output/__init__.py | 0 .../screens/application/no_output/conftest.py | 9 ++ .../no_output/test_audio_settings.py | 39 +++++ .../application/no_output/test_playing.py | 99 +++++++++++++ tests/suite/screens/screen.py | 2 + tests/suite/screens/views/audio_settings.py | 9 +- .../coordinators/playback/test_failures.py | 66 +++++++++ .../coordinators/playback/test_guard.py | 35 +++-- .../coordinators/tabs/test_sequencer.py | 65 ++++++++- .../sequencer/playback/test_tick_clock.py | 3 +- .../logic/sequencer/test_voices.py | 18 ++- .../logic/shared/test_audio_player.py | 19 +++ .../logic/shared/test_file_playback.py | 30 +++- .../services/song_player/test_song_player.py | 43 ++++++ .../ui/panels/dialogs/test_audio_settings.py | 36 +++++ .../sampletones_core/audio/test_manager.py | 138 +++++++++++++++--- 38 files changed, 788 insertions(+), 117 deletions(-) create mode 100644 src/sampletones_application/coordinators/playback/failures.py create mode 100644 tests/screens/application/no_output/__init__.py create mode 100644 tests/screens/application/no_output/conftest.py create mode 100644 tests/screens/application/no_output/test_audio_settings.py create mode 100644 tests/screens/application/no_output/test_playing.py create mode 100644 tests/unit/sampletones_application/coordinators/playback/test_failures.py diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md index a0a14c44a..fe4b1fd5f 100644 --- a/docs/development/application/playback.md +++ b/docs/development/application/playback.md @@ -34,6 +34,8 @@ A report comes from the thread writing the audio, and the mark is a widget, so e Priority ranks the two kinds and settles every contest for the device. Starting intentional playback preempts a sounding preview, and a preview requested while intentional playback holds the device is declined. +**A sound starts only on a device in force.** Every request, of either kind, asks the device manager for the output before a thread or a stream opens, so a machine offering no output device refuses the request at once with `NoOutputDeviceError`. One presenter, `PlaybackFailurePresenter` (`coordinators/playback/failures.py`), tells the reader what stopped any sound: a missing output reads as a plain notice, and every other failure as an error. + ## Engagement A source is **engaged** while it owns the device output, whether it is sounding or held paused. A source therefore reports itself engaged only while *its own* audio is the one on the device. While a preview sounds, ownership rests outside every source and each of them reports itself idle. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 5760e0a88..c559afccb 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -147,12 +147,6 @@ currently out of line. An entry leaves when the code meets the contract again. ## Bugs -* Playing on a machine that offers no output device fails without a word. The song player reads the - device's rate as playback starts, and a recording's playback reads it on its own thread, and with no - device in force both raise `ValueError: No audio device selected` where the user should read that - nothing can play. -* Apply in Audio settings on a machine that offers no output device logs a `KeyError` and leaves the - window open: the window looks the empty device choice up among the devices it lists. * A rerun over a folder skips a recording whose channels changed while the run's channels together stayed the same: a run writes every recording into the one folder named after all its channels, and the rerun skips each recording whose reconstruction stands there, whatever channels that file was made with. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 07b397ab8..47c03100f 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -27,6 +27,7 @@ from sampletones_application.coordinators.export.setup import ExportSetup from sampletones_application.coordinators.keybindings import KeybindingsCoordinator from sampletones_application.coordinators.original_audio import OriginalAudioLocator +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol from sampletones_application.coordinators.playback.router import PlaybackRouter from sampletones_application.coordinators.project import ProjectCoordinator @@ -259,6 +260,10 @@ def __init__( key_router=self.key_router, shortcut_source=self._shortcut_source, ) + self._playback_failures: PlaybackFailurePresenter = PlaybackFailurePresenter( + dialogs=self.dialogs, + language_manager=self.language_manager, + ) self.audio_device_manager: AudioDeviceManager = AudioDeviceManager() self.config_manager = ConfigManager(config_path) @@ -521,6 +526,7 @@ def __init__( layout=ReconstructionTabParameters.from_config(self.layout), language_manager=self.language_manager, dialogs=self.dialogs, + playback_failures=self._playback_failures, status_bar=self.status_bar, ) @@ -538,6 +544,7 @@ def __init__( layout=InstructionsTabParameters.from_config(self.layout), language_manager=self.language_manager, dialogs=self.dialogs, + playback_failures=self._playback_failures, status_bar=self.status_bar, ) @@ -563,6 +570,7 @@ def __init__( layout=MainTabParameters.from_config(self.layout), language_manager=self.language_manager, dialogs=self.dialogs, + playback_failures=self._playback_failures, status_bar=self.status_bar, stem_selection_window=self.stem_selection_window, key_router=self.key_router, @@ -585,6 +593,7 @@ def __init__( layout=SequencerTabParameters.from_config(self.layout), language_manager=self.language_manager, dialogs=self.dialogs, + playback_failures=self._playback_failures, status_bar=self.status_bar, on_edit_voice_requested=self._document_flight( self._reconstruction_coordinator.guard_edit_voice, @@ -1186,9 +1195,9 @@ def _handle_reconstruct_directory(self, directory_path: Path) -> None: def _on_playback_error(self, exception: Exception) -> None: logger.error_with_traceback(exception, "Playback error occurred") - self.dialogs.show_error( + self._playback_failures.present( exception, - self.language_manager["global.dialog.message.audio_playback_error"], + message=self.language_manager["global.dialog.message.audio_playback_error"], ) def _refresh_browsers(self) -> None: diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index af2acabcc..bb2ed7505 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -145,6 +145,7 @@ class GlobalMessageElements(AbstractElement): CONFIGURATION_RECOVERY_PATH_PREFIX = "configuration_recovery_path_prefix" AUDIO_PLAYBACK_ERROR = "audio_playback_error" NO_PROJECT_OPEN = "no_project_open" + NO_AUDIO_OUTPUT = "no_audio_output" REMOVE_VOICE = "remove_voice" CHANGE_NES_FREQUENCY = "change_nes_frequency" FREQUENCY_MISMATCH = "frequency_mismatch" @@ -178,6 +179,7 @@ class GlobalDialogTitleElements(AbstractElement): OPEN_UNSAVED_PROJECT = "open_unsaved_project" CLOSE_UNSAVED_PROJECT = "close_unsaved_project" NO_PROJECT_OPEN = "no_project_open" + NO_AUDIO_OUTPUT = "no_audio_output" REMOVE_VOICE = "remove_voice" CHANGE_NES_FREQUENCY = "change_nes_frequency" FREQUENCY_MISMATCH = "frequency_mismatch" diff --git a/src/sampletones_application/coordinators/playback/failures.py b/src/sampletones_application/coordinators/playback/failures.py new file mode 100644 index 000000000..2b44f9d1c --- /dev/null +++ b/src/sampletones_application/coordinators/playback/failures.py @@ -0,0 +1,49 @@ +from typing import Optional + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT +from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_shared.exceptions import NoOutputDeviceError + + +class PlaybackFailurePresenter: + """Tells the reader why a sound they asked for stays silent, whichever gesture asked for it. + + Every recovery boundary around a playback start hands its failure here, so what the reader sees + is decided in one place. A machine offering no audio output is a fact about the machine, which a + plain notice states in the reader's words. Any other failure is reported as an error with its + details. + """ + + def __init__( + self, + *, + dialogs: DialogsRenderer, + language_manager: LanguageManager, + ) -> None: + self._dialogs = dialogs + self._language_manager = language_manager + + def present( + self, + exception: Exception, + *, + message: Optional[str], + ) -> None: + """Shows the reader what stopped a playback. + + Args: + exception: The failure the playback met. + message: The line an error report opens with, or ``None`` for a report of the error + alone. + """ + match exception: + case NoOutputDeviceError(): + self._dialogs.show_info( + TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT, + self._language_manager["global.dialog.message.no_audio_output"], + self._language_manager["global.dialog.title.no_audio_output"], + modal=True, + ) + case _: + self._dialogs.show_error(exception, message) diff --git a/src/sampletones_application/coordinators/playback/guard.py b/src/sampletones_application/coordinators/playback/guard.py index 38ec4cfb9..05e34b243 100644 --- a/src/sampletones_application/coordinators/playback/guard.py +++ b/src/sampletones_application/coordinators/playback/guard.py @@ -1,12 +1,12 @@ +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol -from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_shared.exceptions import PlaybackError from sampletones_shared.types.callback import VoidCallback class GuardedPlayer: """Drives an ``AudioPlayerProtocol`` player on behalf of panels and the - ``PlaybackRouter``, presenting playback failures as dialogs. + ``PlaybackRouter``, handing playback failures to the presenter. Panels only fire intent hooks, so this wrapper is the coordinator-layer recovery boundary for the transport commands that can raise @@ -17,11 +17,11 @@ def __init__( self, player: AudioPlayerProtocol, *, - dialogs: DialogsRenderer, + failures: PlaybackFailurePresenter, error_message: str, ) -> None: self._player = player - self._dialogs = dialogs + self._failures = failures self._error_message = error_message def play(self) -> None: @@ -46,7 +46,7 @@ def is_loaded(self) -> bool: return self._player.is_loaded() def run_guarded(self, command: VoidCallback) -> None: - """Runs a playback command, presenting a failure to start the audio as a dialog. + """Runs a playback command, showing the reader a failure to start the audio. A command beyond the transport — sounding a sample from a point the reader clicked — goes through the same boundary the transport commands do. @@ -54,4 +54,4 @@ def run_guarded(self, command: VoidCallback) -> None: try: command() except PlaybackError as exception: - self._dialogs.show_error(exception, self._error_message) + self._failures.present(exception, message=self._error_message) diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index 273eb270f..e81f294b9 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -7,6 +7,7 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager from sampletones_application.config.managers.session import SessionManager +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol from sampletones_application.logic.instruction.details import ( @@ -103,6 +104,7 @@ def __init__( layout: InstructionsTabParameters, language_manager: LanguageManager, dialogs: DialogsRenderer, + playback_failures: PlaybackFailurePresenter, status_bar: GUIStatusBar, ) -> None: self._language_manager = language_manager @@ -176,7 +178,7 @@ def __init__( ) self._guarded_player = GuardedPlayer( self._instruction_player_logic, - dialogs=dialogs, + failures=playback_failures, error_message=language_manager["global.player.message.audio_playback_error"], ) self._waveform_panel = GUIInstructionWaveformPanel( diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index daf69af50..3d22830a9 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -7,6 +7,7 @@ from sampletones_application.config.managers.config import ConfigManager from sampletones_application.config.managers.session import SessionManager from sampletones_application.constants.output import OutputKind +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter from sampletones_application.coordinators.tabs.hooks import MainTabHooks from sampletones_application.logic.instruction.library_manager import ( InstructionsLibraryManager, @@ -105,6 +106,7 @@ def __init__( layout: MainTabParameters, language_manager: LanguageManager, dialogs: DialogsRenderer, + playback_failures: PlaybackFailurePresenter, status_bar: GUIStatusBar, stem_selection_window: GUIStemSelectionWindow, key_router: KeyRouter, @@ -117,6 +119,7 @@ def __init__( self._library_manager = library_manager self._hooks = hooks self._dialogs = dialogs + self._playback_failures = playback_failures self._stem_selection_window = stem_selection_window self._geometry = layout.geometry @@ -362,7 +365,7 @@ def _repaint_explorer_favorites(self, node: FileSystemNode) -> None: self._explorer_panel.update_favorite_indicators((node,)) def _on_explorer_autoplay_error(self, exception: Exception) -> None: - FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) + FrameCallbackManager.set_frame_callback(lambda: self._playback_failures.present(exception, message=None)) def _on_converter_view_changed(self, view_model: ConverterViewModel) -> None: """The converter's own view, and the settings card that follows what it has picked out. diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 47e197b1e..1637e6cc2 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -16,6 +16,7 @@ ) from sampletones_application.coordinators.export.setup import ExportSetup from sampletones_application.coordinators.original_audio import OriginalAudioLocator +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol from sampletones_application.logic.history.manager import HistoryManager @@ -156,6 +157,7 @@ def __init__( layout: ReconstructionTabParameters, language_manager: LanguageManager, dialogs: DialogsRenderer, + playback_failures: PlaybackFailurePresenter, status_bar: GUIStatusBar, ) -> None: self._language_manager = language_manager @@ -171,6 +173,7 @@ def __init__( self._format_setups = format_setups self._instrument_exports = instrument_exports self._dialogs = dialogs + self._playback_failures = playback_failures self._original_audio_locator = original_audio_locator self._on_rewrite_requested = on_rewrite_requested @@ -227,7 +230,7 @@ def __init__( ) self._guarded_player = GuardedPlayer( self._reconstruction_player_logic, - dialogs=dialogs, + failures=playback_failures, error_message=language_manager["global.player.message.audio_playback_error"], ) self._reconstruction_audio_panel: GUIReconstructionAudioPanel = GUIReconstructionAudioPanel( @@ -837,7 +840,7 @@ def _on_preview_error(self, exception: Exception) -> None: same way: on the frame after the one that failed, which leaves the gesture that started it finished before a dialog is raised. """ - FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) + FrameCallbackManager.set_frame_callback(lambda: self._playback_failures.present(exception, message=None)) def _on_audio_data_changed(self, audio_data: Optional[AudioData]) -> None: if audio_data is None: diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 3af275e6f..4b88118fd 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -11,6 +11,7 @@ from sampletones_application.coordinators.edit.protocol import EditSurfaceProtocol from sampletones_application.coordinators.export import InstrumentExportCoordinator from sampletones_application.coordinators.original_audio import OriginalAudioLocator +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol from sampletones_application.coordinators.tabs.sequencer.blocks import SequencerBlocks @@ -108,6 +109,7 @@ def __init__( layout: SequencerTabParameters, language_manager: LanguageManager, dialogs: DialogsRenderer, + playback_failures: PlaybackFailurePresenter, status_bar: GUIStatusBar, on_edit_voice_requested: StringCallback, on_favorite_changed: Callable[[FileSystemNode], None], @@ -133,6 +135,7 @@ def __init__( self._after_edits = after_edits self._language_manager = language_manager self._dialogs = dialogs + self._playback_failures = playback_failures self._nes_frequency_change_acknowledged: bool = False @@ -193,7 +196,7 @@ def __init__( ) self._guarded_player = GuardedPlayer( self._song_player_logic, - dialogs=dialogs, + failures=playback_failures, error_message=language_manager["global.player.message.audio_playback_error"], ) self._sequencer_tracker_panel: GUISequencerTrackerPanel = GUISequencerTrackerPanel( @@ -478,7 +481,7 @@ def _wire_order_callbacks(self) -> None: self._frames.clear, detail=self._history_detail.clear_frame, ) - self._sequencer_order_panel.on_play_from_requested = self._frames.play_from + self._sequencer_order_panel.on_play_from_requested = self._play_from_frame self._sequencer_order_panel.on_move_requested = self._recorder.undoable( HistoryAction.MOVE_FRAME, self._frames.move, @@ -647,7 +650,11 @@ def realign_with_project(self) -> None: def play_from_current_frame(self) -> None: """Plays from the frame the tracker is showing, seeking in place when already playing.""" - self._frames.play_from(self._sequencer_tracker_logic.frame_index) + self._play_from_frame(self._sequencer_tracker_logic.frame_index) + + def _play_from_frame(self, position: int) -> None: + """Plays from a frame inside the player's recovery boundary, the one Play goes through.""" + self._guarded_player.run_guarded(partial(self._frames.play_from, position)) def add_instrument(self) -> None: """Appends a hand-written voice to the pool, the menu bar's entry to the gesture.""" @@ -775,7 +782,7 @@ def _on_song_changed(self) -> None: self._sequencer_order_logic.push_order() def _on_player_error(self, error: Exception) -> None: - self._dialogs.show_error(error) + self._playback_failures.present(error, message=None) def _on_player_view_changed(self, view_model: SongPlayerViewModel) -> None: """Settles the marks the transport owns, and how far the grid chases the playhead. @@ -805,7 +812,7 @@ def _on_player_position_changed( self._playhead.mark() def _on_preview_error(self, exception: Exception) -> None: - FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) + FrameCallbackManager.set_frame_callback(lambda: self._playback_failures.present(exception, message=None)) def _on_browser_recordings_requested(self, path: Path) -> None: """Hands the browser what a document names, where it has already been read.""" @@ -841,9 +848,12 @@ def _dispatch_edit_voice(self, voice_id: str) -> None: def _on_tracker_play_from_row(self, row_index: int) -> None: """Starts playback from the right-clicked row of the frame the tracker is showing.""" - self._song_player_logic.play_from( - self._sequencer_tracker_logic.frame_index, - row_index, + self._guarded_player.run_guarded( + partial( + self._song_player_logic.play_from, + self._sequencer_tracker_logic.frame_index, + row_index, + ) ) def _on_voices_changed( diff --git a/src/sampletones_application/logic/reconstruction/audition.py b/src/sampletones_application/logic/reconstruction/audition.py index 5b84be2bd..c5d50af92 100644 --- a/src/sampletones_application/logic/reconstruction/audition.py +++ b/src/sampletones_application/logic/reconstruction/audition.py @@ -21,7 +21,6 @@ from sampletones_core.project.voices.instrument import Instrument from sampletones_shared.constants.music import OCTAVE_OFFSET, OCTAVE_SEMITONES from sampletones_shared.exceptions import PlaybackError -from sampletones_shared.logger import logger from sampletones_shared.utils.callbacks import CallbackMixin @@ -86,7 +85,7 @@ def sound(self, semitone: int) -> None: if audio is None: return - self._play(audio, instrument.id) + self._play(audio) def _waveform(self) -> Optional[InstrumentWaveformViewModel]: """The audio the open instrument makes at the pitch it stands at, drawn as it sounds. @@ -141,12 +140,13 @@ def _audition_config(self) -> Config: sample_rate=settings.sample_rate, ) - def _play(self, audio: np.ndarray, voice_id: str) -> None: + def _play(self, audio: np.ndarray) -> None: """Sounds the rendering, following it with a cursor while the audition holds the output. A preview yields to playback the reader asked for, so the cursor is followed only once the audition has the device: an audition that stands aside leaves the mark of whatever is - sounding where it is. + sounding where it is. A device that refuses the audition reports through + ``on_audition_error``. """ try: sounding = self._audio_device_manager.play( @@ -155,11 +155,7 @@ def _play(self, audio: np.ndarray, voice_id: str) -> None: priority=PlaybackPriority.PREVIEW, owner=self, ) - except (PlaybackError, ValueError) as exception: - logger.error_with_traceback( - exception, - f"Failed to audition instrument: {voice_id}", - ) + except PlaybackError as exception: self.call(self.on_audition_error, exception) return diff --git a/src/sampletones_application/logic/sequencer/playback/song_player.py b/src/sampletones_application/logic/sequencer/playback/song_player.py index 3205bb313..84b7bdf22 100644 --- a/src/sampletones_application/logic/sequencer/playback/song_player.py +++ b/src/sampletones_application/logic/sequencer/playback/song_player.py @@ -96,15 +96,21 @@ def play_from(self, order_position: int, row_index: int = 0) -> None: self._start_from(SongPosition(order_position=order_position, row_index=row_index)) def _start_from(self, position: SongPosition) -> None: + """Starts the song at ``position``, which the playhead takes once the service has the output. + + Raises: + NoOutputDeviceError: If no output device is in force, which leaves the playhead where it + stands. + """ if not self._project_controller.is_open: return - self._position = position - self._awaiting_seek_order = None self._service.start( order_position=position.order_position, row_index=position.row_index, ) + self._position = position + self._awaiting_seek_order = None self._emit_view() self._notify_audio_state_changed() diff --git a/src/sampletones_application/logic/sequencer/voices.py b/src/sampletones_application/logic/sequencer/voices.py index f00488f85..6bf2a9eba 100644 --- a/src/sampletones_application/logic/sequencer/voices.py +++ b/src/sampletones_application/logic/sequencer/voices.py @@ -41,7 +41,6 @@ from sampletones_core.reconstructions import Reconstruction from sampletones_core.utils.display import display_voice from sampletones_shared.exceptions import PlaybackError -from sampletones_shared.logger import logger from sampletones_shared.types.callback import StringCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -331,9 +330,5 @@ def _play_voice( update=False, priority=priority, ) - except (PlaybackError, ValueError) as exception: - logger.error_with_traceback( - exception, - f"Failed to preview voice: {voice_id}", - ) + except PlaybackError as exception: self.call(self.on_autoplay_error, exception) diff --git a/src/sampletones_application/logic/shared/audio_player.py b/src/sampletones_application/logic/shared/audio_player.py index deee1a33f..7d439e3cd 100644 --- a/src/sampletones_application/logic/shared/audio_player.py +++ b/src/sampletones_application/logic/shared/audio_player.py @@ -5,7 +5,6 @@ from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE, START_OF_AUDIO -from sampletones_shared.exceptions import PlaybackError from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -85,6 +84,9 @@ def play(self, *, start: int) -> None: Args: start: The sample playback begins at, clamped to the audio. + + Raises: + NoOutputDeviceError: If no output device is in force. """ if not self.audio_data.is_loaded(): self._notify_audio_state_changed() @@ -93,18 +95,12 @@ def play(self, *, start: int) -> None: self.audio_device_manager.set_position_callback( self._on_device_position_changed, ) - audio = self.audio_data.sample - - try: - self.audio_device_manager.play( - audio, - priority=PlaybackPriority.NORMAL, - owner=self, - start=start, - ) - except ValueError as exception: - raise PlaybackError(f"Audio playback failed: {exception}") from exception - + self.audio_device_manager.play( + self.audio_data.sample, + priority=PlaybackPriority.NORMAL, + owner=self, + start=start, + ) self._notify_audio_state_changed() def pause(self) -> None: diff --git a/src/sampletones_application/logic/shared/file_playback.py b/src/sampletones_application/logic/shared/file_playback.py index 6a0f5ef29..17f233c76 100644 --- a/src/sampletones_application/logic/shared/file_playback.py +++ b/src/sampletones_application/logic/shared/file_playback.py @@ -4,7 +4,7 @@ from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_core.audio import AudioDeviceManager from sampletones_core.reconstructions import Reconstruction -from sampletones_shared.exceptions import SampleToNESError +from sampletones_shared.exceptions import PlaybackError, SampleToNESError from sampletones_shared.logger import logger from sampletones_shared.paths import extensions from sampletones_shared.utils.callbacks import CallbackMixin @@ -38,7 +38,17 @@ def play(self, path: Path) -> None: self.play_at(path, PlaybackPriority.NORMAL) def play_at(self, path: Path, priority: PlaybackPriority) -> None: - """Play a file at the priority the gesture asking for it carries.""" + """Play a file at the priority the gesture asking for it carries. + + A device that refuses the playback reports through ``on_error``, the way a file that + fails to read does. + """ + try: + self._play_file(path, priority) + except PlaybackError as exception: + self.call(self.on_error, exception) + + def _play_file(self, path: Path, priority: PlaybackPriority) -> None: match path.suffix.lower(): case extensions.EXT_FILE_RECONSTRUCTION: self._play_reconstruction(path, priority) diff --git a/src/sampletones_application/services/song_player/service.py b/src/sampletones_application/services/song_player/service.py index c04fe9c8b..1566a31ce 100644 --- a/src/sampletones_application/services/song_player/service.py +++ b/src/sampletones_application/services/song_player/service.py @@ -96,6 +96,15 @@ def start( order_position: int = 0, row_index: int = 0, ) -> None: + """Starts the song at a row, replacing any song this service is playing. + + The device manager is asked for the output first, so a machine offering no output device + refuses the song before any thread starts and leaves the playback as it stands. + + Raises: + NoOutputDeviceError: If no output device is in force. + """ + output = self._audio_device_manager.require_output() self.stop() if self.alive: logger.error(f"{self.class_name}: the previous writer still holds the output; start ignored") @@ -107,7 +116,7 @@ def start( self._prefetch_samples = max( 1, round( - PREFETCH_SECONDS * self._audio_device_manager.sample_rate, + PREFETCH_SECONDS * output.sample_rate, ), ) self._write_block_frames = self._audio_device_manager.buffer_size diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 756061559..b6aef171a 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -620,6 +620,12 @@ Widget.DIALOG, "no_project_open", ) +TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.DIALOG, + "no_audio_output", +) TAG_GLOBAL_DIALOG_PROJECT_OPEN = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py index 0e81f6097..88919be75 100644 --- a/src/sampletones_application/ui/panels/dialogs/audio_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/audio_settings.py @@ -276,9 +276,14 @@ def _commit(self) -> None: """Reports the chosen device, rate and buffer once the window has left the screen. Applying them can fail with a playback error, and that error opens alone once the window - is gone, so the choices are read while the combos still stand. + is gone, so the choices are read while the combos still stand. With no device picked, as on + a machine offering none, the window leaves and the settings stay as they are. """ - device = self._devices_by_label[dpg.get_value(TAG_SETTINGS_AUDIO_COMBO_DEVICE)] + device = self._devices_by_label.get(dpg.get_value(TAG_SETTINGS_AUDIO_COMBO_DEVICE)) + if device is None: + self.hide() + return + sample_rate = self._sample_rates_by_label[dpg.get_value(TAG_SETTINGS_AUDIO_COMBO_SAMPLE_RATE)] buffer_size = BUFFER_SIZE_ITEMS[dpg.get_value(TAG_SETTINGS_AUDIO_COMBO_BUFFER_SIZE)] self._leave_then(partial(self.call, self.on_commit, device.device_index, sample_rate, buffer_size)) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index dd6242987..07a74fbb1 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -43,6 +43,7 @@ global.dialog.title.new_unsaved_project: "New project" global.dialog.title.open_unsaved_project: "Open project" global.dialog.title.close_unsaved_project: "Close project" global.dialog.title.no_project_open: "No project open" +global.dialog.title.no_audio_output: "Can't play sound" global.dialog.title.remove_voice: "Remove voice" global.dialog.title.change_nes_frequency: "Change NES frequency" global.dialog.title.frequency_mismatch: "Different NES frequency" @@ -105,6 +106,7 @@ global.dialog.message.configuration_recovery_list_header: "The following setting global.dialog.message.configuration_recovery_path_prefix: "You can edit the configuration file directly:" global.dialog.message.audio_playback_error: "Audio playback error" global.dialog.message.no_project_open: "No project is open. Create or open a project before adding a reconstruction." +global.dialog.message.no_audio_output: "Can't play sound: no audio output is available." global.dialog.message.remove_voice: "The voice \"{name}\" is used by one or more patterns. Removing it will clear every row that references it. Remove it anyway?" global.dialog.message.change_nes_frequency: "Song playback already follows the new rate. Editing a reconstruction in the Reconstructions tab still uses its own rate until you retune it. Retune all samples to match?" global.dialog.message.frequency_mismatch: "This reconstruction was generated at {reconstruction} Hz, but the project runs at {project} Hz, so it won't play back as intended. Add it anyway?" diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index 9b94acc5f..652971962 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -15,7 +15,7 @@ BufferSize, SampleRate, ) -from sampletones_shared.exceptions import PlaybackError +from sampletones_shared.exceptions import NoOutputDeviceError, PlaybackError from sampletones_shared.logger import logger from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -250,10 +250,10 @@ def device_index(self) -> int: The device index. Raises: - ValueError: If no audio device is currently selected. + NoOutputDeviceError: If no audio device is currently selected. """ if self._device_index is None: - raise ValueError("No audio device selected") + raise NoOutputDeviceError("No audio device selected") return self._device_index @@ -324,10 +324,10 @@ def sample_rate(self) -> SampleRate: Sample rate in Hz. Raises: - ValueError: If no audio device is currently selected. + NoOutputDeviceError: If no audio device is currently selected. """ if self._sample_rate is None: - raise ValueError("No audio device selected") + raise NoOutputDeviceError("No audio device selected") return self._sample_rate @@ -372,6 +372,27 @@ def get_current_device(self) -> Optional[CurrentDevice]: return CurrentDevice.from_device(device, self._sample_rate) + def require_output(self) -> CurrentDevice: + """ + Answer the device and rate a playback opens its stream on. + + Every way to start playback asks here first, so a machine offering no output device, or a + refresh that took the chosen one off the list, refuses the playback before any thread or + stream starts. + + Returns: + The device in force and the rate it plays at. + + Raises: + NoOutputDeviceError: If no listed device is selected. + """ + current_device = self.get_current_device() + if current_device is None: + logger.warning("Playback refused: no audio output device is in force") + raise NoOutputDeviceError("No audio output device is in force") + + return current_device + def set_current_device(self, current_device: CurrentDevice) -> None: """ Configure the manager from a CurrentDevice object. @@ -535,11 +556,17 @@ def play_file( """ Load and play an audio file. + The output is asked for before the file is read, so a refused playback reads nothing. + Args: filepath: Path to the audio file. update: If True, invoke position callback during playback. priority: Output-request priority; see :meth:`play`. + + Raises: + NoOutputDeviceError: If no output device is in force. """ + self.require_output() audio = load_audio(filepath, normalize=False, quantize=False) self.play(audio, update=update, priority=priority) @@ -575,7 +602,11 @@ def play( Returns: bool: Whether this request took the output, which is what a caller following its own playback — drawing a cursor along it — waits for before it starts following. + + Raises: + NoOutputDeviceError: If no output device is in force. """ + output = self.require_output() external_priority = self.call(self.external_output_priority) with self._lock: internal_priority = self._active_priority if self._playing else None @@ -602,7 +633,7 @@ def play( self._resume_event.set() self._playback_thread = threading.Thread( target=self._playback_worker, - kwargs={"update": update}, + kwargs={"output": output, "update": update}, daemon=True, name="AudioPlaybackWorker", ) @@ -674,7 +705,12 @@ def _playback_loop(self, stream: pyaudio.Stream, update: bool) -> None: if update and self._position_callback is not None: self.call(self._position_callback, current_position) - def _playback_worker(self, *, update: bool = True) -> None: + def _playback_worker( + self, + *, + output: CurrentDevice, + update: bool, + ) -> None: """ Playback thread worker function. @@ -682,17 +718,18 @@ def _playback_worker(self, *, update: bool = True) -> None: Handles stream opening errors by invoking the error callback. Args: + output: The device and rate in force when the playback was asked for. update: If True, invoke position callback during playback. """ assert self._pyaudio is not None, "PyAudio instance is not initialized" - logger.debug(f"Starting playback: device_index={self._device_index}, sample_rate={self._sample_rate}") + logger.debug(f"Starting playback: device_index={output.device_index}, sample_rate={output.sample_rate}") try: stream = self._pyaudio.open( format=FORMAT, channels=CHANNELS, - rate=self.sample_rate, + rate=output.sample_rate, output=True, - output_device_index=self._device_index, + output_device_index=output.device_index, ) except OSError as exception: playback_error = PlaybackError(f"Failed to open audio stream: {exception}") @@ -835,17 +872,19 @@ def open_output_stream( Raises: PlaybackError: If PyAudio is not initialized. + NoOutputDeviceError: If no output device is in force. """ if self._pyaudio is None: raise PlaybackError("PyAudio not initialized; call reinitialize() first") + output = self.require_output() self.stop() stream = self._pyaudio.open( format=FORMAT, channels=CHANNELS, rate=sample_rate, output=True, - output_device_index=self._device_index, + output_device_index=output.device_index, frames_per_buffer=buffer_size, ) with self._lock: diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index 9a59ad323..08c783f8c 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -1,4 +1,9 @@ -from .audio import AudioWriteError, PlaybackError, UnsupportedAudioFormatError +from .audio import ( + AudioWriteError, + NoOutputDeviceError, + PlaybackError, + UnsupportedAudioFormatError, +) from .base import SampleToNESError from .callback import CallbackQueueStop from .cuda import CuPyNotInstalledWarning @@ -94,6 +99,7 @@ "MissingTextError", "NoFilesToProcessError", "NoLibraryDataError", + "NoOutputDeviceError", "NotAValidArchiveError", "NotAnInstrumentFileError", "OperationCanceled", diff --git a/src/sampletones_shared/exceptions/audio.py b/src/sampletones_shared/exceptions/audio.py index c7ef79698..a16aa7dca 100644 --- a/src/sampletones_shared/exceptions/audio.py +++ b/src/sampletones_shared/exceptions/audio.py @@ -13,5 +13,9 @@ class PlaybackError(AudioError): """Base class for exceptions raised during playback.""" +class NoOutputDeviceError(PlaybackError): + """Exception raised when playback is asked for while no output device is in force.""" + + class AudioWriteError(AudioError): """Exception raised when audio cannot be written to a file.""" diff --git a/tests/screens/application/no_output/__init__.py b/tests/screens/application/no_output/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/no_output/conftest.py b/tests/screens/application/no_output/conftest.py new file mode 100644 index 000000000..306e2403b --- /dev/null +++ b/tests/screens/application/no_output/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from tests.suite.screens.boundaries.audio import OutputDevice + + +@pytest.fixture +def output_device() -> OutputDevice: + """The machine offers no output device.""" + return OutputDevice.NONE diff --git a/tests/screens/application/no_output/test_audio_settings.py b/tests/screens/application/no_output/test_audio_settings.py new file mode 100644 index 000000000..7086c5d72 --- /dev/null +++ b/tests/screens/application/no_output/test_audio_settings.py @@ -0,0 +1,39 @@ +import operator + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.screen import Screen + + +def open_audio_settings(screen: Screen) -> None: + """Opens Audio settings from its shortcut and waits for it.""" + screen.press_shortcut(ShortcutId.AUDIO_SETTINGS) + screen.expect(screen.audio_settings.is_shown, bool, description="Audio settings") + + +class TestApplyingWithNoDevice: + """With no output device to pick, Apply in Audio settings closes the window and changes nothing. + + Apply closes the window and reports no error. Audio settings then opens again, and Cancel closes + it, which shows the first Apply left the dialog answering as before. + """ + + def test_apply_closes_the_window(self, screen: Screen) -> None: + """The window closes on Apply, and opens and closes again afterwards.""" + settings = screen.audio_settings + + def apply_closes_the_window(screen: Screen) -> None: + open_audio_settings(screen) + + settings.apply() + + screen.expect(settings.is_shown, operator.not_, description="Audio settings closed") + assert not screen.error_notice.is_shown() + + def the_window_opens_again(screen: Screen) -> None: + open_audio_settings(screen) + + screen.press_shortcut(ShortcutId.DIALOG_CANCEL) + + screen.expect(settings.is_shown, operator.not_, description="Audio settings closed") + + screen.scenario(apply_closes_the_window, the_window_opens_again).run() diff --git a/tests/screens/application/no_output/test_playing.py b/tests/screens/application/no_output/test_playing.py new file mode 100644 index 000000000..c334973e7 --- /dev/null +++ b/tests/screens/application/no_output/test_playing.py @@ -0,0 +1,99 @@ +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 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.steps.sequencer import on_the_sequencer +from tests.suite.screens.vocabulary.playback import PLAY +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, sequencer_world + +NO_OUTPUT_TITLE: Final[str] = "global.dialog.title.no_audio_output" +NO_OUTPUT_MESSAGE: Final[str] = "global.dialog.message.no_audio_output" +MIDDLE_OF_THE_WAVEFORM: Final[float] = 0.5 + + +@pytest.fixture +def world() -> World: + """A home holding reconstructions that play and an arranged project.""" + return sequencer_world() + + +@pytest.fixture +def startup() -> Startup: + """The application opens on a reconstruction that plays and on the arranged project.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=ARRANGED_PROJECT) + + +def the_reader_is_told(screen: Screen) -> None: + """Waits for the notice that no sound can play, reads its words, and dismisses it with OK. + + The Playback menu still offers Play, since nothing took the output. + """ + notice = screen.no_output_notice + screen.expect(notice.is_shown, bool, description="the notice that no sound can play") + + assert notice.prompt.title() == screen.words(NO_OUTPUT_TITLE) + assert notice.words() == screen.words(NO_OUTPUT_MESSAGE) + assert not screen.error_notice.is_shown() + assert screen.sequencer.playback.play_entry() == screen.words(PLAY) + + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + + +class TestPlayingAReconstruction: + """With no output device, playing the open reconstruction tells the reader that no sound can play. + + Play brings up the notice with its words and leaves the Playback menu offering Play. Once OK + dismisses it, a click on the waveform, which plays from there, brings the same notice up again. + """ + + def test_play_and_a_click_tell_the_reader(self, screen: Screen) -> None: + """Each gesture brings up the plain notice, and no error is reported.""" + + def play_tells_the_reader(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + screen.press_shortcut(ShortcutId.PLAY) + + the_reader_is_told(screen) + + def a_click_on_the_waveform_tells_the_reader_again(screen: Screen) -> None: + screen.reconstructions.waveform.click(MIDDLE_OF_THE_WAVEFORM) + + the_reader_is_told(screen) + + screen.scenario(play_tells_the_reader, a_click_on_the_waveform_tells_the_reader_again).run() + + +class TestPlayingTheSong: + """With no output device, playing the open project's song tells the reader that no sound can play. + + Play on the Sequencer brings up the notice with its words and leaves the Playback menu offering + Play. Once OK dismisses it, Play from this frame brings the same notice up again. + """ + + def test_play_and_play_from_this_frame_tell_the_reader(self, screen: Screen) -> None: + """Each gesture brings up the plain notice, and no error is reported.""" + + def play_tells_the_reader(screen: Screen) -> None: + on_the_sequencer(screen) + + screen.press_shortcut(ShortcutId.PLAY) + + the_reader_is_told(screen) + + def play_from_this_frame_tells_the_reader_again(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY_FROM_FRAME) + + the_reader_is_told(screen) + + screen.scenario(play_tells_the_reader, play_from_this_frame_tells_the_reader_again).run() diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index f57c839c4..2ba95e7ba 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -12,6 +12,7 @@ SUF_BUTTON, TAG_GLOBAL_DIALOG_ERROR, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND, + TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT, TAG_GLOBAL_STATUS_BAR, TAG_GLOBAL_WINDOW_MAIN, ) @@ -114,6 +115,7 @@ def __init__( 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) + self.no_output_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT) def expect( self, diff --git a/tests/suite/screens/views/audio_settings.py b/tests/suite/screens/views/audio_settings.py index 0c40e9f1e..95f0d795b 100644 --- a/tests/suite/screens/views/audio_settings.py +++ b/tests/suite/screens/views/audio_settings.py @@ -3,6 +3,7 @@ import dearpygui.dearpygui as dpg from sampletones_application.tags.settings import ( + TAG_SETTINGS_AUDIO_BUTTON_APPLY, TAG_SETTINGS_AUDIO_SLIDER_MASTER_GAIN, TAG_SETTINGS_AUDIO_TEXT_MASTER_GAIN_DB, TAG_SETTINGS_AUDIO_WINDOW, @@ -18,8 +19,8 @@ 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 and Apply: the slider, the line reading its level + in decibels, and the button that applies the choices. """ def __init__( @@ -34,6 +35,10 @@ 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 apply(self) -> None: + """Clicks Apply.""" + self._hand.click(TAG_SETTINGS_AUDIO_BUTTON_APPLY) + 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))) diff --git a/tests/unit/sampletones_application/coordinators/playback/test_failures.py b/tests/unit/sampletones_application/coordinators/playback/test_failures.py new file mode 100644 index 000000000..0102d220f --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/playback/test_failures.py @@ -0,0 +1,66 @@ +from typing import Final, Optional +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter +from sampletones_application.paths import LANG_EN +from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT +from sampletones_shared.exceptions import NoOutputDeviceError, PlaybackError + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +NO_OUTPUT_MESSAGE: Final[str] = "global.dialog.message.no_audio_output" +NO_OUTPUT_TITLE: Final[str] = "global.dialog.title.no_audio_output" +ERROR_MESSAGE: Final[str] = "playback failed" + + +@pytest.fixture(name="dialogs") +def dialogs_fixture() -> MagicMock: + return MagicMock() + + +@pytest.fixture(name="presenter") +def presenter_fixture(dialogs: MagicMock) -> PlaybackFailurePresenter: + return PlaybackFailurePresenter(dialogs=dialogs, language_manager=LANGUAGE_MANAGER) + + +class TestNoOutput: + """A machine offering no audio output reads as a plain notice, whatever line the caller offers.""" + + @pytest.mark.parametrize("message", [ERROR_MESSAGE, None], ids=["with_a_message", "without_a_message"]) + def test_a_missing_output_reads_as_a_notice( + self, + presenter: PlaybackFailurePresenter, + dialogs: MagicMock, + message: Optional[str], + ) -> None: + presenter.present(NoOutputDeviceError("no device"), message=message) + + dialogs.show_info.assert_called_once_with( + TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT, + LANGUAGE_MANAGER[NO_OUTPUT_MESSAGE], + LANGUAGE_MANAGER[NO_OUTPUT_TITLE], + modal=True, + ) + dialogs.show_error.assert_not_called() + + +class TestOtherFailures: + """Any other failure reads as an error report opening with the caller's line.""" + + @pytest.mark.parametrize( + "exception", + [PlaybackError("stream refused"), OSError("device busy")], + ids=["playback", "io"], + ) + def test_a_failure_reads_as_an_error( + self, + presenter: PlaybackFailurePresenter, + dialogs: MagicMock, + exception: Exception, + ) -> None: + presenter.present(exception, message=ERROR_MESSAGE) + + dialogs.show_error.assert_called_once_with(exception, ERROR_MESSAGE) + dialogs.show_info.assert_not_called() diff --git a/tests/unit/sampletones_application/coordinators/playback/test_guard.py b/tests/unit/sampletones_application/coordinators/playback/test_guard.py index f71f644be..957550967 100644 --- a/tests/unit/sampletones_application/coordinators/playback/test_guard.py +++ b/tests/unit/sampletones_application/coordinators/playback/test_guard.py @@ -4,7 +4,7 @@ import pytest from sampletones_application.coordinators.playback.guard import GuardedPlayer -from sampletones_shared.exceptions import PlaybackError +from sampletones_shared.exceptions import NoOutputDeviceError, PlaybackError GUARDED_COMMANDS = ("play", "pause_or_resume") @@ -15,17 +15,17 @@ def player_logic() -> MagicMock: @pytest.fixture -def dialogs() -> MagicMock: +def failures() -> MagicMock: return MagicMock() @pytest.fixture -def guarded_player(player_logic: MagicMock, dialogs: MagicMock) -> GuardedPlayer: - return GuardedPlayer(player_logic, dialogs=dialogs, error_message="playback failed") +def guarded_player(player_logic: MagicMock, failures: MagicMock) -> GuardedPlayer: + return GuardedPlayer(player_logic, failures=failures, error_message="playback failed") class TestGuardedCommands: - """The transport commands that can raise ``PlaybackError`` surface it as a dialog instead of + """The transport commands that can raise ``PlaybackError`` hand it to the presenter instead of propagating, so a panel hook or the playback router can invoke them bare.""" @pytest.mark.parametrize("command", GUARDED_COMMANDS) @@ -33,42 +33,47 @@ def test_delegates_to_the_logic( self, guarded_player: GuardedPlayer, player_logic: MagicMock, - dialogs: MagicMock, + failures: MagicMock, command: str, ) -> None: getattr(guarded_player, command)() getattr(player_logic, command).assert_called_once_with() - dialogs.show_error.assert_not_called() + failures.present.assert_not_called() @pytest.mark.parametrize("command", GUARDED_COMMANDS) - def test_playback_error_becomes_a_dialog( + @pytest.mark.parametrize( + "exception", + [PlaybackError("device unavailable"), NoOutputDeviceError("no device")], + ids=["playback", "no_output"], + ) + def test_a_playback_error_reaches_the_presenter( self, guarded_player: GuardedPlayer, player_logic: MagicMock, - dialogs: MagicMock, + failures: MagicMock, command: str, + exception: PlaybackError, ) -> None: - exception = PlaybackError("device unavailable") getattr(player_logic, command).side_effect = exception getattr(guarded_player, command)() - dialogs.show_error.assert_called_once_with(exception, "playback failed") + failures.present.assert_called_once_with(exception, message="playback failed") class TestAGuardedRun: """A command beyond the transport runs under the same boundary the transport commands keep.""" - def test_the_command_runs(self, guarded_player: GuardedPlayer, dialogs: MagicMock) -> None: + def test_the_command_runs(self, guarded_player: GuardedPlayer, failures: MagicMock) -> None: ran: List[int] = [] guarded_player.run_guarded(lambda: ran.append(400)) assert ran == [400] - dialogs.show_error.assert_not_called() + failures.present.assert_not_called() - def test_playback_error_becomes_a_dialog(self, guarded_player: GuardedPlayer, dialogs: MagicMock) -> None: + def test_a_playback_error_reaches_the_presenter(self, guarded_player: GuardedPlayer, failures: MagicMock) -> None: exception = PlaybackError("device unavailable") def failing() -> None: @@ -76,4 +81,4 @@ def failing() -> None: guarded_player.run_guarded(failing) - dialogs.show_error.assert_called_once_with(exception, "playback failed") + failures.present.assert_called_once_with(exception, message="playback failed") diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 6a87ac84d..ab499e90f 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -69,6 +69,7 @@ from sampletones_shared.exceptions import ( InvalidReconstructionValuesError, MalformedInstrumentError, + NoOutputDeviceError, ) from tests.suite.gates import HeldGate, held_gate from tests.suite.language import FakeLanguageManager @@ -1775,7 +1776,7 @@ def exposure_coordinator() -> SequencerTabCoordinator: instance._song_player_logic = MagicMock() instance._guarded_player = GuardedPlayer( instance._song_player_logic, - dialogs=MagicMock(), + failures=MagicMock(), error_message="playback failed", ) return instance @@ -1789,6 +1790,68 @@ def test_player_returns_the_guarded_wrapper( assert isinstance(exposure_coordinator.player, GuardedPlayer) +@pytest.fixture(name="failures") +def failures_fixture() -> MagicMock: + return MagicMock() + + +@pytest.fixture(name="refusing_coordinator") +def refusing_coordinator_fixture(failures: MagicMock) -> SequencerTabCoordinator: + """A coordinator whose song player is refused by a machine offering no output device.""" + instance = object.__new__(SequencerTabCoordinator) + instance._song_player_logic = MagicMock() + instance._song_player_logic.play_from.side_effect = NoOutputDeviceError("no device") + instance._song_player_logic.is_playing.return_value = False + instance._sequencer_tracker_logic = MagicMock() + instance._frames = SequencerFrames( + MagicMock(), + instance._sequencer_tracker_logic, + instance._song_player_logic, + MagicMock(), + MagicMock(), + ) + instance._guarded_player = GuardedPlayer( + instance._song_player_logic, + failures=failures, + error_message="playback failed", + ) + return instance + + +class TestPlayingFromAPlaceWithNoOutput: + """Every way to play the song from a place reaches the presenter with the refusal, the way Play does.""" + + def test_play_from_this_frame_reaches_the_presenter( + self, + refusing_coordinator: SequencerTabCoordinator, + failures: MagicMock, + ) -> None: + refusing_coordinator.play_from_current_frame() + + failures.present.assert_called_once() + assert isinstance(failures.present.call_args.args[0], NoOutputDeviceError) + + def test_play_from_a_row_reaches_the_presenter( + self, + refusing_coordinator: SequencerTabCoordinator, + failures: MagicMock, + ) -> None: + refusing_coordinator._on_tracker_play_from_row(4) + + failures.present.assert_called_once() + assert isinstance(failures.present.call_args.args[0], NoOutputDeviceError) + + def test_play_from_an_order_frame_reaches_the_presenter( + self, + refusing_coordinator: SequencerTabCoordinator, + failures: MagicMock, + ) -> None: + refusing_coordinator._play_from_frame(1) + + failures.present.assert_called_once() + assert isinstance(failures.present.call_args.args[0], NoOutputDeviceError) + + PULSE1_CELL: Final[TrackerRegion] = TrackerRegion( first_row=0, last_row=0, diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py index c39478e46..44f5bd928 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -9,6 +9,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName from sampletones_core.timing import SONG_TICK_BOUNDS, SongTiming, TickClock +from sampletones_shared.exceptions import NoOutputDeviceError from tests.suite.base import BaseTestSuite from tests.suite.performance import make_pulse_reconstruction from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( @@ -166,7 +167,7 @@ def __init__(self) -> None: def __call__(self) -> int: self.reads += 1 if self.rate is None: - raise ValueError("No audio device selected") + raise NoOutputDeviceError("No audio device selected") return self.rate diff --git a/tests/unit/sampletones_application/logic/sequencer/test_voices.py b/tests/unit/sampletones_application/logic/sequencer/test_voices.py index 1d325244d..345c33544 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_voices.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_voices.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Dict, Tuple +from typing import Callable, Dict, List, Tuple from unittest.mock import MagicMock import numpy as np @@ -31,7 +31,7 @@ from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.note_on import NoteOn from sampletones_core.reconstructions import Reconstruction -from sampletones_shared.exceptions import LoadInstrumentError +from sampletones_shared.exceptions import LoadInstrumentError, NoOutputDeviceError from tests.suite.sequencer import sample_reconstruction @@ -360,6 +360,20 @@ def test_unknown_sample_is_ignored(self) -> None: audio_device_manager.play.assert_not_called() + def test_a_device_refusing_the_preview_reports_the_refusal( + self, reconstruction_factory: Callable[[], Reconstruction] + ) -> None: + controller, logic, _, audio_device_manager = _logic_with_mocks() + sample = controller.add_sample(reconstruction_factory(), name="lead") + refusal = NoOutputDeviceError("no device") + audio_device_manager.play.side_effect = refusal + reported: List[Exception] = [] + logic.on_autoplay_error = reported.append + + logic.play_voice(sample.id) + + assert reported == [refusal] + class TestAutoplay: def test_executes_pending_preview_when_autoplay_enabled( diff --git a/tests/unit/sampletones_application/logic/shared/test_audio_player.py b/tests/unit/sampletones_application/logic/shared/test_audio_player.py index 40e1264ba..3125a805f 100644 --- a/tests/unit/sampletones_application/logic/shared/test_audio_player.py +++ b/tests/unit/sampletones_application/logic/shared/test_audio_player.py @@ -7,6 +7,7 @@ from sampletones_application.logic.shared.audio_player import AudioPlayer from sampletones_application.view_model.shared.audio_data import AudioData from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE, START_OF_AUDIO +from sampletones_shared.exceptions import NoOutputDeviceError from tests.suite.application import HeldQueue from tests.suite.device import FakeAudioDevice @@ -111,6 +112,24 @@ def test_the_device_is_started_at_the_sample_asked_for(self, player: AudioPlayer assert device.position == 30 +class TestARefusedPlay: + """A device with no output in force refuses the play, and the refusal reaches the caller's recovery + boundary as the device raised it, with no change of state reported.""" + + def test_the_refusal_reaches_the_caller(self) -> None: + refusal = NoOutputDeviceError("no device") + player, device = _player(owned=False, paused=False) + player.load_audio_data(AudioData.from_array(np.zeros(AUDIO_LENGTH, dtype=np.float32), DEFAULT_SAMPLE_RATE)) + device.play.side_effect = refusal + reports = Reports(player) + + with pytest.raises(NoOutputDeviceError) as raised: + player.play(start=START_OF_AUDIO) + + assert raised.value is refusal + assert reports.state_changes == 0 + + class TestDeviceReportsCrossToTheRenderThread: """A report from the playback thread waits for the render loop, and reads the device when it runs.""" diff --git a/tests/unit/sampletones_application/logic/shared/test_file_playback.py b/tests/unit/sampletones_application/logic/shared/test_file_playback.py index c858bdfdb..9f4ea5bba 100644 --- a/tests/unit/sampletones_application/logic/shared/test_file_playback.py +++ b/tests/unit/sampletones_application/logic/shared/test_file_playback.py @@ -5,7 +5,7 @@ from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.playback_priority import PlaybackPriority -from sampletones_shared.exceptions import InvalidReconstructionError +from sampletones_shared.exceptions import InvalidReconstructionError, NoOutputDeviceError from sampletones_shared.paths import extensions RECONSTRUCTION_LOAD = "sampletones_application.logic.shared.file_playback.Reconstruction.load" @@ -87,3 +87,31 @@ def test_an_unexpected_failure_propagates(self, tmp_path: Path) -> None: playback.play(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") playback.on_error.assert_not_called() + + +class TestADeviceThatRefuses: + """A device with no output in force refuses the playback, and the refusal reports through ``on_error`` + for an audio file and for a reconstruction alike.""" + + def test_an_audio_file_reports_the_refusal(self, tmp_path: Path) -> None: + refusal = NoOutputDeviceError("no device") + audio_device_manager = MagicMock() + audio_device_manager.play_file.side_effect = refusal + playback = FilePlayback(audio_device_manager) + playback.on_error = MagicMock() + + playback.play_at(tmp_path / "audio.wav", PlaybackPriority.PREVIEW) + + playback.on_error.assert_called_once_with(refusal) + + def test_a_reconstruction_reports_the_refusal(self, tmp_path: Path) -> None: + refusal = NoOutputDeviceError("no device") + audio_device_manager = MagicMock() + audio_device_manager.play.side_effect = refusal + playback = FilePlayback(audio_device_manager) + playback.on_error = MagicMock() + + with patch(RECONSTRUCTION_LOAD): + playback.play(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") + + playback.on_error.assert_called_once_with(refusal) diff --git a/tests/unit/sampletones_application/services/song_player/test_song_player.py b/tests/unit/sampletones_application/services/song_player/test_song_player.py index 1c825649f..4e90fa04c 100644 --- a/tests/unit/sampletones_application/services/song_player/test_song_player.py +++ b/tests/unit/sampletones_application/services/song_player/test_song_player.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch import numpy as np +import pytest from sampletones_application.services.song_player.result import ( SongPlaybackError, @@ -14,9 +15,12 @@ SongPlayerService, _RenderedRow, ) +from sampletones_core.audio.device import CurrentDevice from sampletones_core.project.song_position import SongPosition +from sampletones_shared.exceptions import NoOutputDeviceError SAMPLE_RATE: Final[int] = 44100 +SPEAKERS: Final[CurrentDevice] = CurrentDevice(device_index=0, name="Speakers", sample_rate=SAMPLE_RATE, host_api=0) WRITE_BLOCK: Final[int] = 64 WAIT_TIMEOUT: Final[float] = 5.0 SHORT_JOIN_TIMEOUT: Final[float] = 0.05 @@ -113,6 +117,7 @@ def _close_stream(stream: _FakeStream) -> None: def _make_device_manager(stream: Optional[_FakeStream] = None) -> MagicMock: """A device manager that winds a handed-back stream down as the real one does.""" audio_device_manager = MagicMock() + audio_device_manager.require_output.return_value = SPEAKERS audio_device_manager.sample_rate = SAMPLE_RATE audio_device_manager.buffer_size = WRITE_BLOCK audio_device_manager.open_output_stream.return_value = stream @@ -540,6 +545,44 @@ def test_start_is_refused_while_a_worker_still_holds_the_output(self) -> None: gate.set() +class TestSongPlayerServiceWithNoOutput: + """A machine offering no output device refuses the song as it starts, before a thread starts. + + The refusal is the device manager's own, raised from ``start`` for the caller's recovery boundary, + and the service stays idle with its synthesizer where it stood. A device in force starts the song. + """ + + def test_start_raises_the_refusal_and_stays_idle(self) -> None: + audio_device_manager = _make_device_manager(_FakeStream()) + audio_device_manager.require_output.side_effect = NoOutputDeviceError("no device") + synthesizer = MagicMock() + service = SongPlayerService( + audio_device_manager, + synthesizer, + should_loop=lambda: False, + master_gain=lambda: 1.0, + ) + + with pytest.raises(NoOutputDeviceError): + service.start(order_position=2, row_index=3) + + assert service.alive is False + assert service._render_thread is None + assert service._write_thread is None + synthesizer.set_position.assert_not_called() + audio_device_manager.open_output_stream.assert_not_called() + + def test_a_device_in_force_starts_the_song(self) -> None: + stream = _FakeStream() + service = _make_streaming_service(_make_device_manager(stream)) + service.subscribe(lambda result: None) + + service.start() + + assert stream.entered_write.wait(timeout=WAIT_TIMEOUT) + service.stop() + + class TestSongPlayerServiceEndOfSong: """The song lets go of the output before it reports how it ended, so a listener reads it free.""" diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_audio_settings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_audio_settings.py index 9f05e0e92..88d05af4b 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_audio_settings.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_audio_settings.py @@ -85,3 +85,39 @@ def test_the_owner_hears_what_the_window_held( held_frames.render() assert committed == [(DEVICE_INDEX, DEFAULT_SAMPLE_RATE, DEFAULT_BUFFER_SIZE)] + + +class TestApplyingWithNoDevice: + """Apply on a machine offering no output device closes the window and leaves the settings as they + are: the owner hears nothing, even once the frames after it have run.""" + + @pytest.fixture(name="nothing_offered") + def nothing_offered_fixture(self, window: GUIAudioSettingsWindow) -> List[Committed]: + """The settings the owner heard, from a window built over a machine offering no device.""" + committed: List[Committed] = [] + window.on_commit = lambda device_index, sample_rate, buffer_size: committed.append( + (device_index, sample_rate, buffer_size) + ) + window._seed( + AudioSettingsViewModel( + devices=(), + current_device_index=None, + current_sample_rate=None, + buffer_size=DEFAULT_BUFFER_SIZE, + master_gain=UNITY_GAIN, + ) + ) + window.create_window() + return committed + + def test_the_window_closes_and_the_owner_hears_nothing( + self, + window: GUIAudioSettingsWindow, + nothing_offered: List[Committed], + held_frames: Frames, + ) -> None: + window._commit() + held_frames.render() + + assert not dpg.does_item_exist(TAG_SETTINGS_AUDIO_WINDOW) + assert nothing_offered == [] diff --git a/tests/unit/sampletones_core/audio/test_manager.py b/tests/unit/sampletones_core/audio/test_manager.py index bf5c21166..7f756245a 100644 --- a/tests/unit/sampletones_core/audio/test_manager.py +++ b/tests/unit/sampletones_core/audio/test_manager.py @@ -1,15 +1,16 @@ import threading from dataclasses import dataclass +from pathlib import Path from typing import Callable, Dict, Final, Iterator, List, Union from unittest.mock import MagicMock, patch import numpy as np import pytest -from sampletones_core.audio.device import CurrentDevice +from sampletones_core.audio.device import AudioDevice, CurrentDevice from sampletones_core.audio.manager import AudioDeviceManager from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE, START_OF_AUDIO, SampleRate -from sampletones_shared.exceptions import PlaybackError +from sampletones_shared.exceptions import NoOutputDeviceError, PlaybackError from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -28,6 +29,15 @@ "defaultSampleRate": DEFAULT_SAMPLE_RATE, "hostApi": _SPEAKERS_HOST_API, } +_SPEAKERS_DEVICE: Final[AudioDevice] = AudioDevice( + index=_SPEAKERS_INDEX, + name=_SPEAKERS_NAME, + default_sample_rate=DEFAULT_SAMPLE_RATE, + supported_sample_rates=[DEFAULT_SAMPLE_RATE], + host_api=_SPEAKERS_HOST_API, +) +_AUDIO_FILE: Final[str] = "recording.wav" +_LOAD_AUDIO: Final[str] = "sampletones_core.audio.manager.load_audio" def _manager() -> AudioDeviceManager: @@ -38,7 +48,9 @@ def _manager() -> AudioDeviceManager: """ manager = object.__new__(AudioDeviceManager) manager._pyaudio = MagicMock() - manager._device_index = 0 + manager._devices = {_SPEAKERS_INDEX: _SPEAKERS_DEVICE} + manager._device_index = _SPEAKERS_INDEX + manager._sample_rate = DEFAULT_SAMPLE_RATE manager._lock = threading.Lock() manager._resume_event = threading.Event() manager._playing = False @@ -364,6 +376,27 @@ def test_a_release_may_hand_its_stream_back_from_the_writing_thread(self) -> Non assert manager._pyaudio is None +@pytest.fixture(name="backend") +def backend_fixture() -> Iterator[MagicMock]: + """The audio backend of a machine offering one pair of speakers as its default output.""" + with patch(_BACKEND) as backend_class: + backend = backend_class.return_value + backend.get_device_count.return_value = 1 + backend.get_device_info_by_index.return_value = _SPEAKERS + backend.get_default_output_device_info.return_value = _SPEAKERS + yield backend + + +@pytest.fixture(name="silent_backend") +def silent_backend_fixture() -> Iterator[MagicMock]: + """The audio backend of a machine offering no output device at all.""" + with patch(_BACKEND) as backend_class: + backend = backend_class.return_value + backend.get_device_count.return_value = 0 + backend.get_default_output_device_info.side_effect = OSError + yield backend + + class TestCurrentDevice: """The device in force reads as ``None`` wherever nothing is selected. @@ -372,25 +405,6 @@ class TestCurrentDevice: with a device answers the device and the rate it plays at. """ - @pytest.fixture(name="backend") - def backend_fixture(self) -> Iterator[MagicMock]: - """The audio backend of a machine offering one pair of speakers as its default output.""" - with patch(_BACKEND) as backend_class: - backend = backend_class.return_value - backend.get_device_count.return_value = 1 - backend.get_device_info_by_index.return_value = _SPEAKERS - backend.get_default_output_device_info.return_value = _SPEAKERS - yield backend - - @pytest.fixture(name="silent_backend") - def silent_backend_fixture(self) -> Iterator[MagicMock]: - """The audio backend of a machine offering no output device at all.""" - with patch(_BACKEND) as backend_class: - backend = backend_class.return_value - backend.get_device_count.return_value = 0 - backend.get_default_output_device_info.side_effect = OSError - yield backend - def test_the_default_device_is_in_force(self, backend: MagicMock) -> None: manager = AudioDeviceManager() @@ -423,3 +437,83 @@ def test_configuring_answers_the_device_now_in_force(self, backend: MagicMock) - assert configured.sample_rate == _CHOSEN_RATE assert manager.get_current_device() == configured + + +class TestPlayingWithNoOutput: + """Every way to start a sound asks the manager for the output first, and with none in force the + manager refuses at once, before a thread or a stream starts. + + Each refusal is the typed ``NoOutputDeviceError``, which every recovery boundary catches as a + ``PlaybackError``. A machine offering speakers answers the device and rate the stream opens on. + """ + + @pytest.fixture(name="silent_manager") + def silent_manager_fixture(self, silent_backend: MagicMock) -> AudioDeviceManager: + """A manager on a machine offering no output device.""" + return AudioDeviceManager() + + def test_a_device_in_force_answers_what_the_stream_opens_on(self, backend: MagicMock) -> None: + manager = AudioDeviceManager() + + assert manager.require_output() == manager.get_current_device() + + def test_asking_for_the_output_is_refused(self, silent_manager: AudioDeviceManager) -> None: + with pytest.raises(NoOutputDeviceError): + silent_manager.require_output() + + def test_a_refusal_is_a_playback_error(self) -> None: + assert issubclass(NoOutputDeviceError, PlaybackError) + + def test_playing_audio_is_refused_before_a_thread_starts(self, silent_manager: AudioDeviceManager) -> None: + with ( + patch("sampletones_core.audio.manager.threading.Thread") as thread, + pytest.raises(NoOutputDeviceError), + ): + silent_manager.play(np.zeros(4, dtype=np.float32), priority=_HIGH) + + thread.assert_not_called() + assert silent_manager.is_playing() is False + + def test_playing_a_file_is_refused_before_the_file_is_read(self, silent_manager: AudioDeviceManager) -> None: + with ( + patch(_LOAD_AUDIO) as load_audio, + pytest.raises(NoOutputDeviceError), + ): + silent_manager.play_file(Path(_AUDIO_FILE)) + + load_audio.assert_not_called() + + def test_a_stream_is_refused_before_it_opens( + self, + silent_manager: AudioDeviceManager, + silent_backend: MagicMock, + ) -> None: + with pytest.raises(NoOutputDeviceError): + silent_manager.open_output_stream(sample_rate=DEFAULT_SAMPLE_RATE, buffer_size=800, release=MagicMock()) + + silent_backend.open.assert_not_called() + assert silent_manager._stream_owners == {} + + def test_the_rate_reads_as_the_same_refusal(self, silent_manager: AudioDeviceManager) -> None: + with pytest.raises(NoOutputDeviceError): + _ = silent_manager.sample_rate + + def test_a_device_taken_off_the_list_refuses_a_playback(self, backend: MagicMock) -> None: + manager = AudioDeviceManager() + backend.get_device_count.return_value = 0 + manager.refresh_devices() + + with ( + patch("sampletones_core.audio.manager.threading.Thread") as thread, + pytest.raises(NoOutputDeviceError), + ): + manager.play(np.zeros(4, dtype=np.float32)) + + thread.assert_not_called() + + def test_a_playback_opens_its_stream_on_the_device_in_force(self, backend: MagicMock) -> None: + manager = AudioDeviceManager() + with patch("sampletones_core.audio.manager.threading.Thread") as thread: + manager.play(np.zeros(4, dtype=np.float32)) + + assert thread.call_args.kwargs["kwargs"]["output"] == manager.get_current_device() From e2eaf6503e0e9d5669abbcd03a3b9b6da6d4c0ae Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 10:36:45 +0200 Subject: [PATCH 38/84] Fixed: Reconstruct file and Reconstruct directory converting at once instead of listing what they name --- docs/guide/converting.md | 3 + src/sampletones_application/application.py | 4 +- .../coordinators/tabs/main.py | 46 +++++----- .../logic/main/converter/logic.py | 34 ++++---- src/sampletones_config/lang/en.yaml | 6 +- tests/screens/main/run/test_replacing.py | 72 ++++++++++------ .../coordinators/tabs/test_main.py | 86 +++++++++---------- .../logic/main/converter/test_logic.py | 68 +++++++++++---- 8 files changed, 187 insertions(+), 132 deletions(-) diff --git a/docs/guide/converting.md b/docs/guide/converting.md index 74066443e..db6d40826 100644 --- a/docs/guide/converting.md +++ b/docs/guide/converting.md @@ -14,6 +14,9 @@ browser: **Add as stem**. A [stem](../glossary.md#stem) is one recording that goes into a reconstruction. - Ctrl-click a folder, or right-click it and choose **Add folder**, to add every recording inside it, at any depth in the folder tree. +- **Reconstruction ▸ Reconstruct file...** and **Reconstruct directory...**, and the same commands on a + right-click in the browser, add recordings for one reconstruction each. If the list holds a mix, the + app asks before replacing it. Turn on **Playback ▸ Autoplay** (`Ctrl+P`) to play a recording with a single click. This lets you listen through a folder before adding anything from it. With Autoplay off, right-click a recording and diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 07b397ab8..0f3d7779e 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1159,7 +1159,7 @@ def _export_reconstruction_instruments_dialog(self, export_format: ExportFormat) self._reconstructions_tab.request_export_instruments_dialog(export_format) def _reconstruct_file(self, filepath: Path) -> None: - self._main_tab.convert_path(filepath) + self._main_tab.take_up_path(filepath) self.session_manager.set_audio_input_path(filepath.parent) self._set_current_tab(Tab.MAIN) self._update_menu() @@ -1175,7 +1175,7 @@ def _handle_reconstruct_file(self, filepath: Path) -> None: self._main_tab.request_reconstruct_file(filepath) def _reconstruct_directory(self, directory_path: Path) -> None: - self._main_tab.convert_path(directory_path) + self._main_tab.take_up_path(directory_path) self.session_manager.set_audio_input_path(directory_path) self._set_current_tab(Tab.MAIN) self._update_menu() diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 8e398ed60..182bc5602 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -379,31 +379,31 @@ def _repaint_converter(self, view_model: ConverterViewModel) -> None: self._hooks.on_busy_state_changed() def request_reconstruct_file(self, filepath: Path) -> None: - """Converts the recording a Reconstruct named, asking first where it drops what was gathered.""" + """Lists the recording a Reconstruct named, asking first where it would replace a mix.""" if self._notify_converter_running(): return - self._replacing_the_setup(lambda: self._hooks.on_reconstruct_file(filepath)) + self._giving_way_to_one_apiece(lambda: self._hooks.on_reconstruct_file(filepath)) def request_reconstruct_directory(self, directory_path: Path) -> None: - """Converts the folder a Reconstruct named, asking first where it drops what was gathered.""" + """Lists the folder a Reconstruct named, asking first where it would replace a mix.""" if self._notify_converter_running(): return - self._replacing_the_setup(lambda: self._hooks.on_reconstruct_directory(directory_path)) + self._giving_way_to_one_apiece(lambda: self._hooks.on_reconstruct_directory(directory_path)) - def _replacing_the_setup(self, reconstruct: VoidCallback) -> None: - """Runs a conversion the browser asked for, asking first where it would drop what was gathered. + def _giving_way_to_one_apiece(self, take_up: VoidCallback) -> None: + """Takes up what a Reconstruct named, asking first about a mix it would replace. - A Reconstruct names one file or one folder and converts that alone, so a setup already - holding sources is what the reader is being asked about. Declining leaves the setup as it - stands and starts nothing. + A Reconstruct lists recordings to convert one apiece, so a list of that kind takes them + beside what it holds and an empty mix gives way at once. A mix holding recordings is the + reader's work, so it gives way once they confirm; declining leaves it as it stands. """ - if not self._converter_logic.gathered_paths: - reconstruct() + if not (self._converter_logic.mixes and self._converter_logic.gathered_paths): + take_up() return - self._confirm_discarding_stems(reconstruct) + self._confirm_discarding_stems(take_up) def _confirm_discarding_stems(self, on_confirm: VoidCallback) -> None: self._dialogs.show_confirmation( @@ -548,10 +548,14 @@ def _nothing_below(self, directory_path: Path) -> None: self._language_manager["main.converter.title.scan_dialog"], ) - def _convert_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: - """Converts what the walk found.""" + def _take_up_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: + """Lists what the walk found for a Reconstruct, saying so where the folder holds no recordings.""" self._scan_window.close() - self._converter_logic.convert_folder(directory_path, found) + if not found: + self._nothing_below(directory_path) + return + + self._converter_logic.take_up_folder(directory_path, found) def _mixing_beyond_room(self, found: Tuple[Path, ...]) -> bool: """Whether what was read brings in more than the mix has room for, which is a question. @@ -769,17 +773,17 @@ def is_converter_panel_visible(self) -> bool: def refresh_converter_view(self) -> None: self._converter_logic.refresh_view() - def convert_path(self, path: Path) -> None: - """Converts exactly what a Reconstruct named, replacing whatever the reader gathered. + def take_up_path(self, path: Path) -> None: + """Lists what a Reconstruct named, a recording or a folder, for the reader to convert one apiece. - A folder is read before it is converted, which is work the reader watches rather than - waits blindly through. + A folder is read before it is listed, which is work the reader watches rather than waits + blindly through. """ if not path.is_dir(): - self._converter_logic.convert_recording(path) + self._converter_logic.take_up_recording(path) return - self._folder_scan.start(path, self._convert_read) + self._folder_scan.start(path, self._take_up_read) def save_browser_shape(self) -> None: """Writes down the folders the explorer stands open, so a later run reads down to them.""" diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index f989491cb..f1b60b904 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -212,30 +212,30 @@ def gather_folder(self, root: Path, found: Sequence[Path]) -> None: self._rewrite(self._state.with_gathering(self._gathering_folder(root, found))) - def convert_recording(self, path: Path) -> None: - """Converts exactly the recording the reader named, which is what a Reconstruct asks for. + def take_up_recording(self, path: Path) -> None: + """Lists the recording a Reconstruct names, to be converted into a reconstruction of its own. - The setup is replaced only where a conversion may start, so a Reconstruct reaching the - converter while another operation runs leaves what the reader gathered standing. + A Reconstruct asks for one reconstruction per recording, so a mix standing in the converter + gives way to a list of that kind, and a list of that kind takes the recording beside what it + holds. The reader starts the run once the list and its settings are as they want them. """ - if self._declines_to_start(): - return - - self._replace_setup() + self._turn_to_one_apiece() self.gather_recordings([path]) - self.start_conversion() - def convert_folder(self, root: Path, found: Sequence[Path]) -> None: - """Converts the recordings ``found`` below a folder, writing one reconstruction apiece.""" - if self._declines_to_start(): - return + def take_up_folder(self, root: Path, found: Sequence[Path]) -> None: + """Lists the folder a Reconstruct names, standing for the recordings ``found`` below it. - self._replace_setup() + The folder joins a list writing one reconstruction per recording, as :meth:`take_up_recording` + describes. + """ + self._turn_to_one_apiece() self.gather_folder(root, found) - self.start_conversion() - def _replace_setup(self) -> None: - """Lets whatever was gathered go, since a Reconstruct names what it converts on its own.""" + def _turn_to_one_apiece(self) -> None: + """Lets a mix go for an empty list writing one reconstruction per recording.""" + if not self.mixes: + return + self._rewrite( self._state.with_settings(self._settings.with_output(OutputKind.PER_RECORDING)).with_gathering( Gathering.empty() diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index e7a8d8390..51a6b257f 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -431,16 +431,16 @@ main.converter.label.hierarchy_mode: "Order" main.converter.label.hierarchy_round_robin: "Round robin" main.converter.label.hierarchy_strict: "Strict" main.converter.label.discard_stems_button: "Replace it" -main.converter.label.keep_stems_button: "Keep the list" +main.converter.label.keep_stems_button: "Keep the mix" main.converter.label.add_stems_button: "Add" main.converter.label.overwrite_target_button: "Convert anyway" main.converter.message.hierarchy_mode_tooltip: "Round robin gives every level a turn each round. Strict fills a level before the next one picks." main.converter.message.stems_empty_hint: "Double-click a recording in the browser to add it. Add a folder to convert everything inside it." -main.converter.message.discard_stems_prompt: "This replaces the recordings already in the converter. Continue?" +main.converter.message.discard_stems_prompt: "This replaces the mix with a list that converts each recording on its own. Continue?" main.converter.message.overwrite_target_prompt: "A reconstruction with this name already exists. Converting replaces it." main.converter.message.overwrite_targets_prompt: "{count} reconstructions already exist. Converting replaces them." main.converter.message.stem_selection_prompt: "Pick the recordings to mix." -main.converter.title.discard_stems_dialog: "Replace the list?" +main.converter.title.discard_stems_dialog: "Replace the mix?" main.converter.title.overwrite_target_dialog: "Replace it?" main.converter.title.overwrite_targets_dialog: "Replace them?" main.converter.title.stem_selection_dialog: "Pick recordings to mix" diff --git a/tests/screens/main/run/test_replacing.py b/tests/screens/main/run/test_replacing.py index b7a721d75..56686d3c6 100644 --- a/tests/screens/main/run/test_replacing.py +++ b/tests/screens/main/run/test_replacing.py @@ -3,13 +3,16 @@ from pathlib import Path from typing import Dict, Final, List -from tests.screens.main.run.constants import BASS, DRUMS, LEAD, RECONSTRUCTION_SUFFIX +from sampletones_application.constants.output import OutputKind +from sampletones_shared.paths.user import RECONSTRUCTIONS_DIRECTORY +from tests.screens.main.run.constants import ALBUM, 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" +RECONSTRUCT_DIRECTORY: Final[str] = "main.explorer.label.context_reconstruct_directory" class TestReplacingWhatARunWrote: @@ -68,45 +71,64 @@ def convert_anyway_replaces_those_alone(screen: Screen) -> None: ).run() -class TestReconstructingAFileWhileAListStands: - """Reconstruct file with a list gathered asks first: Keep the list converts nothing, Replace it - converts the file. +class TestReconstructingLists: + """Reconstruct lists what it names to convert one apiece and leaves the run to the reader; over a mix it + asks first. - 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. + Two recordings are gathered and Reconstruct file chosen on a third: it joins the list and nothing is + written. The list then turns into a mix, and Reconstruct directory on the Album asks about it: Keep the + mix leaves it standing, and Replace it lists the Album alone, one reconstruction per recording, with + nothing written. """ - def test_keep_the_list_then_replace_it(self, screen: Screen) -> None: - converter = screen.main.converter + def test_a_list_takes_it_and_a_mix_is_asked_about(self, screen: Screen) -> None: + main = screen.main + converter = main.converter prompt = converter.replace_prompt - destinations: List[Path] = [] + listed = [home_path(BASS), home_path(LEAD), home_path(DRUMS)] + standing: List[List[Path]] = [] + + def choose_from_the_browser(screen: Screen, path: Path, entry: str) -> None: + screen.explorer.right_click(explorer_row(screen, path)) + screen.expect(screen.context_menu.is_shown, bool, description="the browser's menu") + screen.context_menu.choose(screen.words(entry)) - def ask_from_the_browser(screen: Screen) -> None: + def a_list_takes_the_file(screen: Screen) -> None: + standing.append(written(RECONSTRUCTIONS_DIRECTORY)) 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") + choose_from_the_browser(screen, home_path(DRUMS), RECONSTRUCT_FILE) - def keep_the_list(screen: Screen) -> None: + screen.expect( + lambda: converter.list.has_row(home_path(DRUMS)), bool, description="the third recording listed" + ) + assert converter.list.rows() == [converter.list.row(path) for path in listed] + assert not prompt.is_shown() + assert not converter.run_shown() + assert written(RECONSTRUCTIONS_DIRECTORY) == standing[0] + + def keep_the_mix(screen: Screen) -> None: + main.choose_output(OutputKind.MIXED) + screen.expect(main.output, OutputKind.MIXED.__eq__, description="a mix") + + choose_from_the_browser(screen, home_path(ALBUM), RECONSTRUCT_DIRECTORY) + screen.expect(prompt.is_shown, bool, description="the question about the mix") 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]) == [] + assert main.output() is OutputKind.MIXED + assert all(converter.list.has_row(path) for path in listed) 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)) + choose_from_the_browser(screen, home_path(ALBUM), RECONSTRUCT_DIRECTORY) 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.expect(lambda: converter.list.has_row(home_path(ALBUM)), bool, description="the Album listed") + assert main.output() is OutputKind.PER_RECORDING + assert converter.list.rows() == [converter.list.row(home_path(ALBUM))] + assert not converter.run_shown() + assert written(RECONSTRUCTIONS_DIRECTORY) == standing[0] - screen.scenario(ask_from_the_browser, keep_the_list, replace_it).run() + screen.scenario(a_list_takes_the_file, keep_the_mix, replace_it).run() diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index c8c4252e0..3c5de1d86 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -14,7 +14,6 @@ from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION from sampletones_application.tags.main import ( TAG_MAIN_CONVERTER_DIALOG_CANCEL, - TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS, TAG_MAIN_CONVERTER_DIALOG_LOAD, TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, @@ -434,31 +433,41 @@ def test_declining_converts_nothing(self, tmp_path: Path) -> None: coordinator._converter_logic.start_conversion.assert_not_called() -class TestReconstructReplacesTheSetup: - """A Reconstruct converts what it names alone, so a setup already holding sources is asked about.""" +class TestReconstructListsWhatItNames: + """A Reconstruct lists what it names to convert one apiece, asking first only about a mix it replaces.""" def _coordinator(self, *, mixes: bool, gathered: Tuple[Path, ...] = ()) -> MainTabCoordinator: return _stems_coordinator(mixes=mixes, gathered=gathered) - def test_an_empty_setup_reconstructs_straight_away(self, tmp_path: Path) -> None: - coordinator = self._coordinator(mixes=False) + @pytest.mark.parametrize("gathered", [(), (Path("/audio/a.wav"),)]) + def test_a_list_writing_one_apiece_takes_it_straight_away(self, tmp_path: Path, gathered: Tuple[Path, ...]) -> None: + coordinator = self._coordinator(mixes=False, gathered=gathered) coordinator.request_reconstruct_file(tmp_path / "a.wav") coordinator._hooks.on_reconstruct_file.assert_called_once_with(tmp_path / "a.wav") coordinator._dialogs.show_confirmation.assert_not_called() - @pytest.mark.parametrize("gesture", ["request_reconstruct_file", "request_reconstruct_directory"]) - def test_a_gathered_list_is_asked_about_first(self, tmp_path: Path, gesture: str) -> None: + def test_an_empty_mix_gives_way_straight_away(self, tmp_path: Path) -> None: + coordinator = self._coordinator(mixes=True) + + coordinator.request_reconstruct_directory(tmp_path) + + coordinator._hooks.on_reconstruct_directory.assert_called_once_with(tmp_path) + coordinator._dialogs.show_confirmation.assert_not_called() + + def test_a_mix_holding_recordings_is_asked_about_first(self, tmp_path: Path) -> None: coordinator = self._coordinator(mixes=True, gathered=(Path("/audio/a.wav"),)) - getattr(coordinator, gesture)(tmp_path) + coordinator.request_reconstruct_file(tmp_path / "b.wav") + coordinator.request_reconstruct_directory(tmp_path) coordinator._hooks.on_reconstruct_file.assert_not_called() coordinator._hooks.on_reconstruct_directory.assert_not_called() - assert coordinator._dialogs.show_confirmation.call_args.args[1] == DISCARD_STEMS_PROMPT_KEY + prompts = [call.args[1] for call in coordinator._dialogs.show_confirmation.call_args_list] + assert prompts == [DISCARD_STEMS_PROMPT_KEY, DISCARD_STEMS_PROMPT_KEY] - def test_confirming_converts_what_was_named(self, tmp_path: Path) -> None: + def test_confirming_lists_what_was_named(self, tmp_path: Path) -> None: coordinator = self._coordinator(mixes=True, gathered=(Path("/audio/a.wav"),)) coordinator.request_reconstruct_directory(tmp_path) @@ -466,7 +475,7 @@ def test_confirming_converts_what_was_named(self, tmp_path: Path) -> None: coordinator._hooks.on_reconstruct_directory.assert_called_once_with(tmp_path) - def test_declining_converts_nothing(self, tmp_path: Path) -> None: + def test_declining_keeps_the_mix(self, tmp_path: Path) -> None: coordinator = self._coordinator(mixes=True, gathered=(Path("/audio/a.wav"),)) coordinator.request_reconstruct_directory(tmp_path) @@ -476,49 +485,36 @@ def test_declining_converts_nothing(self, tmp_path: Path) -> None: coordinator._hooks.on_reconstruct_directory.assert_not_called() -class TestReconstructingAFileOverAGatheredList: - """Reconstruct on a file while recordings stand gathered asks about them first, and the - conversion it goes on to can ask about writing over a reconstruction in turn.""" +class TestTakingUpWhatAReconstructNamed: + """A recording is listed at once; a folder is read first and listed once the reading is done.""" - @pytest.fixture(name="coordinator") - def coordinator_fixture(self, tmp_path: Path) -> MainTabCoordinator: - coordinator = _stems_coordinator(mixes=True, gathered=(Path("/audio/a.wav"),)) - coordinator._hooks.on_reconstruct_file.side_effect = lambda path: coordinator._confirm_overwriting_target( - (path.with_suffix(".stn"),) - ) - coordinator.request_reconstruct_file(tmp_path / "a.wav") - return coordinator + def test_a_recording_is_listed(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=False) - def test_the_gathered_list_is_asked_about_first(self, coordinator: MainTabCoordinator) -> None: - assert coordinator._dialogs.show_confirmation.call_args.args[0] == TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS - coordinator._hooks.on_reconstruct_file.assert_not_called() + coordinator.take_up_path(tmp_path / "take.wav") - def test_replacing_the_list_reaches_the_conversion( - self, - coordinator: MainTabCoordinator, - tmp_path: Path, - ) -> None: - coordinator._dialogs.show_confirmation.call_args.args[3]() + coordinator._converter_logic.take_up_recording.assert_called_once_with(tmp_path / "take.wav") + coordinator._converter_logic.start_conversion.assert_not_called() - coordinator._hooks.on_reconstruct_file.assert_called_once_with(tmp_path / "a.wav") + def test_a_folder_is_listed_with_what_it_holds(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=False) + root = _folder_of(tmp_path, 2) - def test_the_conversion_asks_about_the_file_it_writes_over_next( - self, - coordinator: MainTabCoordinator, - tmp_path: Path, - ) -> None: - coordinator._dialogs.show_confirmation.call_args.args[3]() + settled(lambda: coordinator.take_up_path(root)) - overwrite = coordinator._dialogs.show_confirmation.call_args - assert overwrite.args[0] == TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET - assert overwrite.kwargs["path"] == tmp_path / "a.stn" + listed, found = coordinator._converter_logic.take_up_folder.call_args.args + assert listed == root + assert {path.name for path in found} == {"take_00.wav", "take_01.wav"} + coordinator._converter_logic.start_conversion.assert_not_called() - def test_confirming_the_overwrite_starts_the_conversion(self, coordinator: MainTabCoordinator) -> None: - coordinator._dialogs.show_confirmation.call_args.args[3]() + def test_a_folder_holding_no_recordings_says_so(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=False) + root = _folder_of(tmp_path, 0) - coordinator._dialogs.show_confirmation.call_args.args[3]() + settled(lambda: coordinator.take_up_path(root)) - coordinator._converter_logic.start_conversion.assert_called_once_with(confirmed=True) + coordinator._converter_logic.take_up_folder.assert_not_called() + assert coordinator._dialogs.show_info.call_args.args[1] == NOTHING_BELOW_KEY def _rows_holding(*counts: int) -> Tuple[MagicMock, ...]: diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index 4d974005e..e7330c294 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -630,29 +630,13 @@ def test_a_reconstruct_request_leaves_the_gathered_recordings_alone( converter_logic: ConverterLogic, tmp_path: Path, ) -> None: - """A **Reconstruct** names its own recording, which lets the whole setup go where it runs.""" + """A **Reconstruct** lists what it names, and the list stands inert while the run holds it.""" source = self._waiting(converter_logic, tmp_path) other = tmp_path / "take.wav" other.touch() - converter_logic.convert_recording(other) - - assert converter_logic.gathered_paths == (source,) - - def test_a_reconstruct_during_another_operation_leaves_the_setup_standing( - self, - converter_logic: ConverterLogic, - tmp_path: Path, - ) -> None: - """A library generation holds the resources too, and a Reconstruct reaching the converter - meanwhile leaves what the reader gathered.""" - source = _aimed_at_a_recording(converter_logic, tmp_path) - other = tmp_path / "take.wav" - other.touch() - converter_logic._is_operation_active = lambda: True - - converter_logic.convert_recording(other) - converter_logic.convert_folder(tmp_path, [other]) + converter_logic.take_up_recording(other) + converter_logic.take_up_folder(tmp_path, [other]) assert converter_logic.gathered_paths == (source,) @@ -816,6 +800,52 @@ def test_a_row_reports_the_level_it_landed_on(self, converter_logic: ConverterLo ] +class TestWhatAReconstructLists(BaseTestSuite): + """A Reconstruct lists what it names for a run writing one reconstruction apiece, and starts nothing. + + The reader sees the recordings and their settings before converting, so the run is theirs to start. + """ + + def test_a_recording_joins_the_list_beside_what_it_holds( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _listed(converter_logic, "bass") + + converter_logic.take_up_recording(Path("/audio/lead.wav")) + + assert converter_logic.gathered_paths == (Path("/audio/bass.wav"), Path("/audio/lead.wav")) + assert converter_logic.mixes is False + service.start.assert_not_called() + + def test_a_folder_joins_the_list_as_a_folder( + self, + converter_logic: ConverterLogic, + service: MagicMock, + tmp_path: Path, + ) -> None: + root = tmp_path / "album" + root.mkdir() + found = [root / "a.wav", root / "b.wav"] + for recording in found: + recording.touch() + + converter_logic.take_up_folder(root, found) + + assert [row.name for row in _view(converter_logic).stem_sources] == [root.name] + assert converter_logic.gathered_paths == tuple(found) + service.start.assert_not_called() + + def test_a_mix_gives_way_to_a_list_of_what_was_named(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "bass", "lead") + + converter_logic.take_up_recording(Path("/audio/drums.wav")) + + assert converter_logic.mixes is False + assert converter_logic.gathered_paths == (Path("/audio/drums.wav"),) + + class TestAnsweringWhichRecordingsToMix(BaseTestSuite): """A mix reaching a fixed number of recordings is put to the reader, and the answer is what the mix is then built from: what it names joins, and what it leaves out goes.""" From 8a58519aa603523eef100996426442ef3aa1223d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 10:41:00 +0200 Subject: [PATCH 39/84] Added: a tooltip on the greyed-out New instrument saying a project brings it back --- .../categories/elements/sequencer.py | 1 + src/sampletones_application/tags/sequencer.py | 18 +++++++ .../ui/panels/sequencer/voices/panel.py | 49 ++++++++++++++----- src/sampletones_config/lang/en.yaml | 1 + .../sequencer/voices/test_voices_card.py | 20 +++++++- tests/suite/screens/views/sequencer.py | 12 +++++ .../ui/panels/sequencer/voices/test_menu.py | 16 +++++- 7 files changed, 103 insertions(+), 14 deletions(-) diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index ac29f18e9..979b672d6 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -71,6 +71,7 @@ class SequencerOrderElements(AbstractElement): class SequencerVoicesElements(AbstractElement): VOICES_TEXT = "voices_text" NEW_INSTRUMENT = "new_instrument" + NEW_INSTRUMENT_NO_PROJECT = "new_instrument_no_project" ADD_SAMPLE = "add_sample" IMPORT_INSTRUMENT = "import_instrument" KIND_SAMPLE = "kind_sample" diff --git a/src/sampletones_application/tags/sequencer.py b/src/sampletones_application/tags/sequencer.py index 9f7499b13..bd29a11ce 100644 --- a/src/sampletones_application/tags/sequencer.py +++ b/src/sampletones_application/tags/sequencer.py @@ -158,6 +158,24 @@ Widget.BUTTON, "new_instrument", ) +TAG_SEQUENCER_VOICES_GROUP_NEW_INSTRUMENT = TagName( + Page.SEQUENCER, + Panel.VOICES, + Widget.GROUP, + "new_instrument", +) +TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT = TagName( + Page.SEQUENCER, + Panel.VOICES, + Widget.TOOLTIP, + "new_instrument", +) +TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT_NO_PROJECT = TagName( + Page.SEQUENCER, + Panel.VOICES, + Widget.TOOLTIP, + "new_instrument_no_project", +) TAG_SEQUENCER_VOICES_TABLE = TagName( Page.SEQUENCER, Panel.VOICES, diff --git a/src/sampletones_application/ui/panels/sequencer/voices/panel.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py index a51dd5a4c..7b1ae8b58 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -12,10 +12,13 @@ from sampletones_application.tags.general import SUF_HANDLER_LIST, SUF_HANDLER_REGISTRY from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, + TAG_SEQUENCER_VOICES_GROUP_NEW_INSTRUMENT, TAG_SEQUENCER_VOICES_INPUT_RENAME, TAG_SEQUENCER_VOICES_PANEL, TAG_SEQUENCER_VOICES_TABLE, TAG_SEQUENCER_VOICES_THEME_ROW, + TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT, + TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT_NO_PROJECT, TAG_SEQUENCER_VOICES_WINDOW, ) from sampletones_application.ui.elements.context_menu import context_menu_under_pointer @@ -46,7 +49,7 @@ from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource -from sampletones_application.utils.gui.tooltip import show_tooltip +from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip, set_tooltip_visible, show_tooltip from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.view_model.sequencer.voices import ( SequencerVoicesViewModel, @@ -92,6 +95,10 @@ def __init__( self._entries: Tuple[VoiceEntryViewModel, ...] = () self._accepts_voices = False self._tip_new_instrument = self._tooltip(language_manager, SequencerVoicesElements.NEW_INSTRUMENT) + self._tip_new_instrument_no_project = self._tooltip( + language_manager, + SequencerVoicesElements.NEW_INSTRUMENT_NO_PROJECT, + ) self._tip_kind_sample = self._tooltip(language_manager, SequencerVoicesElements.KIND_SAMPLE) self._tip_kind_instrument = self._tooltip(language_manager, SequencerVoicesElements.KIND_INSTRUMENT) self._footprint_text = VoiceFootprintText(language_manager) @@ -205,25 +212,43 @@ def _create_key_handler(self) -> None: ) def _create_new_instrument_button(self) -> None: - """Offers a hand-written voice, which is the one kind no browser brings in.""" - button = dpg.add_button( - tag=TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, - label=self._label(self._language_manager, SequencerVoicesElements.NEW_INSTRUMENT), - width=-1, - enabled=self._accepts_voices, - callback=lambda: self.call(self.on_new_instrument_requested), - ) + """Offers a hand-written voice, which is the one kind no browser brings in. + + A greyed-out button shows no tooltip of its own, so the group around it says what brings + the button back while no project is open. + """ + with dpg.group(tag=TAG_SEQUENCER_VOICES_GROUP_NEW_INSTRUMENT): + button = dpg.add_button( + tag=TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, + label=self._label(self._language_manager, SequencerVoicesElements.NEW_INSTRUMENT), + width=-1, + enabled=self._accepts_voices, + callback=lambda: self.call(self.on_new_instrument_requested), + ) + FontRegistry.bind_to_item(button, Font.REGULAR_SMALL) - show_tooltip(button, self._tip_new_instrument) + show_tooltip(button, self._tip_new_instrument, tag=TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT) + attach_disabled_tooltip( + TAG_SEQUENCER_VOICES_GROUP_NEW_INSTRUMENT, + self._tip_new_instrument_no_project, + tag=TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT_NO_PROJECT, + ) + self._show_new_instrument_tooltip() def set_enabled(self, enabled: bool) -> None: """Offers the ways a voice comes in while a project stands open to take one. - The button above the list and the pool section of every menu the list raises follow the - one answer. + The button above the list, its tooltip and the pool section of every menu the list raises + follow the one answer. """ self._accepts_voices = enabled dpg_configure_item(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, enabled=enabled) + self._show_new_instrument_tooltip() + + def _show_new_instrument_tooltip(self) -> None: + """Shows the tooltip that fits the button's state: what it adds, or what brings it back.""" + set_tooltip_visible(TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT, self._accepts_voices) + set_tooltip_visible(TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT_NO_PROJECT, not self._accepts_voices) @property def accepts_voices(self) -> bool: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 07a74fbb1..a8ccfaaca 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -682,6 +682,7 @@ sequencer.voices.label.omission_cumulative_bend: "a bend outrunning its arpeggio sequencer.voices.label.omission_release_point: "a release point" sequencer.voices.label.omission_arpeggio_mode: "an arpeggio in fixed, relative or scheme mode" sequencer.voices.tooltip.new_instrument: "Add an instrument playable on any channel" +sequencer.voices.tooltip.new_instrument_no_project: "Open or create a project to add voices." sequencer.voices.tooltip.kind_sample: "Sample" sequencer.voices.tooltip.kind_instrument: "Instrument" sequencer.voices.message.instrument_not_found: "The instrument file could not be found." diff --git a/tests/screens/sequencer/voices/test_voices_card.py b/tests/screens/sequencer/voices/test_voices_card.py index 5dd5ba23a..72f1718b9 100644 --- a/tests/screens/sequencer/voices/test_voices_card.py +++ b/tests/screens/sequencer/voices/test_voices_card.py @@ -29,6 +29,8 @@ ) PLAY_VOICE: Final[str] = "global.context.label.play" +NEW_INSTRUMENT_TIP: Final[str] = "sequencer.voices.tooltip.new_instrument" +NO_PROJECT_TIP: Final[str] = "sequencer.voices.tooltip.new_instrument_no_project" STATUS_SAMPLE: Final[str] = "sequencer.voices.template.status_sample" STATUS_SEPARATOR: Final[str] = "sequencer.voices.template.status_channel_separator" @@ -203,7 +205,8 @@ def open_they_act(screen: Screen) -> None: class TestNewInstrumentWithNoProjectOpen: """With no project open, every way a voice comes in stands greyed out and the voice list stays empty. - A new project brings the ways back, and New instrument then adds one voice. + Hovering the greyed-out New instrument says what brings it back. A new project brings the ways back, + hovering New instrument then says what it adds, and a click adds one voice. """ def test_nothing_is_offered_until_a_project_opens(self, screen: Screen) -> None: @@ -221,6 +224,14 @@ def close_the_project(screen: Screen) -> None: def every_way_in_stands_greyed_out(screen: Screen) -> None: screen.expect(lambda: not voices.new_instrument_answers(), bool, description="New instrument greyed out") + voices.hover_new_instrument() + screen.expect( + partial(screen.shows_text, screen.words(NO_PROJECT_TIP)), + bool, + description="the tooltip saying what brings New instrument back", + ) + assert not screen.shows_text(screen.words(NEW_INSTRUMENT_TIP)) + right_click_the_empty_list(screen) screen.expect(menu.is_shown, bool, description="the list's menu") entries = menu.entries() @@ -235,6 +246,13 @@ def every_way_in_stands_greyed_out(screen: Screen) -> None: def a_new_project_takes_a_new_instrument(screen: Screen) -> None: screen.project.create() screen.expect(voices.new_instrument_answers, bool, description="New instrument answering") + voices.hover_new_instrument() + screen.expect( + partial(screen.shows_text, screen.words(NEW_INSTRUMENT_TIP)), + bool, + description="New instrument's own tooltip", + ) + assert not screen.shows_text(screen.words(NO_PROJECT_TIP)) voices.new_instrument() diff --git a/tests/suite/screens/views/sequencer.py b/tests/suite/screens/views/sequencer.py index 6f658e29c..beb88b67a 100644 --- a/tests/suite/screens/views/sequencer.py +++ b/tests/suite/screens/views/sequencer.py @@ -93,6 +93,18 @@ def new_instrument_answers(self) -> bool: """Whether New instrument above the list answers a press, which it does while a project is open.""" return self._bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT)).enabled + def hover_new_instrument(self) -> None: + """Rests the pointer on New instrument above the list, whether it answers or stands greyed out. + + Raises: + LookupError: If the button reports no box. + """ + box = self._bridge.ask(lambda: read_item(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT)).rect + if box is None: + raise LookupError("New instrument stands nowhere") + + self._hand.move_to(box.center) + def names(self) -> List[str]: """The names the rows show, top to bottom.""" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py index 421460d7a..deeef3312 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/voices/test_menu.py @@ -9,6 +9,8 @@ from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, TAG_SEQUENCER_VOICES_PANEL, + TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT, + TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT_NO_PROJECT, ) from sampletones_application.ui.elements import context_menu as context_menu_module from sampletones_application.ui.elements.fonts.registry import FontRegistry @@ -720,24 +722,36 @@ def test_the_items_are_grayed_out_with_no_project( assert [item.enabled for item in recorder.items] == [False, False, False] @pytest.mark.parametrize("enabled", [True, False]) - def test_the_button_and_the_menus_follow_one_answer( + def test_the_button_its_tooltip_and_the_menus_follow_one_answer( self, monkeypatch: pytest.MonkeyPatch, recorder: _MenuRecorder, enabled: bool, ) -> None: + """The button's own tooltip shows while it answers, and the one naming what brings it back while it + stands greyed out.""" configured: List[Tuple[str, bool]] = [] + tooltips: List[Tuple[str, bool]] = [] monkeypatch.setattr( panel_module, "dpg_configure_item", lambda tag, **kwargs: configured.append((tag, kwargs["enabled"])), ) + monkeypatch.setattr( + panel_module, + "set_tooltip_visible", + lambda tag, visible: tooltips.append((tag, visible)), + ) fixture = _panel(monkeypatch, accepts_voices=not enabled) fixture.panel.set_enabled(enabled) fixture.menu.add_pool_items() assert configured == [(TAG_SEQUENCER_VOICES_BUTTON_NEW_INSTRUMENT, enabled)] + assert tooltips == [ + (TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT, enabled), + (TAG_SEQUENCER_VOICES_TOOLTIP_NEW_INSTRUMENT_NO_PROJECT, not enabled), + ] assert [item.enabled for item in recorder.items] == [enabled] * len(recorder.items) From f0233091960d257ac2591355e2ee192ed076311f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 10:45:55 +0200 Subject: [PATCH 40/84] Fixed: Cancel in Display settings leaving a hand-sized window at the nearest offered size --- .../coordinators/display.py | 41 +++++---- .../interface/display/test_discard_prompt.py | 88 ++++++++++++++++++- .../coordinators/test_display.py | 68 +++++++++++++- 3 files changed, 177 insertions(+), 20 deletions(-) diff --git a/src/sampletones_application/coordinators/display.py b/src/sampletones_application/coordinators/display.py index 698daf01e..1fe61e361 100644 --- a/src/sampletones_application/coordinators/display.py +++ b/src/sampletones_application/coordinators/display.py @@ -28,9 +28,10 @@ class DisplayCoordinator: countdown that returns a window mode nobody confirmed. A change reaches the screen the moment it is made, so a user judges it by looking at it, while - the session keeps the values the dialog opened with until OK commits them. Cancel re-applies - that snapshot, asking first when there is something to lose, and a run that ends while the - dialog stands puts it back the same way before the session is written. + the session keeps the values the dialog opened with until OK commits them. Cancel puts back the + display the dialog opened with, a windowed window at its own place and size, asking first when + there is something to lose, and a run that ends while the dialog stands puts it back the same + way before the session is written. Changing the window's size, its frame, or fullscreen can leave the window unreadable, so each of those arms a countdown over the dialog: keeping it disarms the clock and leaves the change @@ -93,18 +94,12 @@ def cleanup(self) -> None: """Puts back the display the dialog opened with when the run ends while it stands, as Cancel does. The session keeps the opening values until OK, and leaving records the window as it stands, so - the window returns to its opening place and size before that record is taken. A fullscreen - change reaches DearPyGui's reading of the window on a drawn frame alone, while a place and a - size set directly reach it at once, so a window that opened windowed is placed as well. + the window returns to its opening place and size before that record is taken. """ - snapshot = self._snapshot - placement = self._opening_placement - if snapshot is None or placement is None: + if self._snapshot is None: return self._discard() - if not snapshot.window.fullscreen: - self._viewport_manager.place(placement) def tick(self, delta_time: float) -> None: """Advances an armed countdown, restoring the last confirmed window mode when it runs out.""" @@ -208,13 +203,29 @@ def _ask_to_discard(self) -> None: ) def _discard(self) -> None: - """Puts back the settings the dialog opened with and closes it.""" + """Puts back the display the dialog opened with and closes it.""" self._disarm() + self._restore_opening_display() + self._close() + + def _restore_opening_display(self) -> None: + """Re-applies the settings the dialog opened with, and puts a windowed window back at its own place + and size. + + A window sitting at a size of its own opens the dialog on the offered size nearest it, and the + placement taken at opening is what carries the size the window had. A place and a size set + directly reach DearPyGui's reading of the window at once, while a fullscreen change reaches it + on a drawn frame alone, so a window that opened windowed is placed after its settings, and a + record of the window taken right after names its opening place and size. + """ snapshot = self._snapshot - if snapshot is not None: - self._apply(self._require_settings(), snapshot) + placement = self._opening_placement + if snapshot is None or placement is None: + return - self._close() + self._apply(self._require_settings(), snapshot) + if not snapshot.window.fullscreen: + self._viewport_manager.place(placement) def _close(self) -> None: self._settings = None diff --git a/tests/screens/interface/display/test_discard_prompt.py b/tests/screens/interface/display/test_discard_prompt.py index 21b0cfbcc..651a3c482 100644 --- a/tests/screens/interface/display/test_discard_prompt.py +++ b/tests/screens/interface/display/test_discard_prompt.py @@ -1,8 +1,26 @@ import operator -from typing import List +from typing import Final, List +import pytest + +from sampletones_application.config.session.state.state import ApplicationState +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, + kept, + open_display_settings, + size_named, + window_position, + window_size, +) from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World + +HAND_SIZE: Final[Resolution] = Resolution(width=1300, height=820) +HAND_X: Final[int] = 100 +HAND_Y: Final[int] = 60 class TestDiscardingADisplayChange: @@ -79,3 +97,71 @@ def test_escape_closes_an_unchanged_dialog(self, screen: Screen) -> None: screen.expect(settings.is_shown, operator.not_, description="the dialog closed") assert not settings.discard_prompt.is_shown() + + +class TestDiscardingOnAHandSizedWindow: + """Discarding a new size puts a window the user sized by hand back at its own size and place. + + The window opens between the sizes Display settings offers, so the dialog names the offered size + nearest it. The scenario keeps another size and discards it, and expects the window back at the + size and place it opened with. + """ + + @pytest.fixture + def world(self) -> World: + """A home whose window the user sized by hand, between the sizes Display settings offers.""" + return World( + state=ApplicationState( + viewport=ViewportState( + width=HAND_SIZE.width, + height=HAND_SIZE.height, + x=HAND_X, + y=HAND_Y, + ) + ), + application_config=None, + config=None, + files=(), + ) + + def test_discarding_puts_back_the_window_s_own_size(self, screen: Screen) -> None: + """The window stands at its own size and place once the new size is discarded.""" + settings = screen.display_settings + prompt = settings.discard_prompt + + def a_size_between_the_offered_ones(screen: Screen) -> None: + assert window_size(screen) == (HAND_SIZE.width, HAND_SIZE.height) + assert window_position(screen) == (HAND_X, HAND_Y) + + open_display_settings(screen) + + assert HAND_SIZE not in [size_named(label) for label in settings.resolutions()] + assert size_named(settings.resolution()) != HAND_SIZE + + def keep_another_size(screen: Screen) -> None: + 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 discard_it(screen: Screen) -> None: + 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") + screen.expect( + lambda: window_size(screen), + (HAND_SIZE.width, HAND_SIZE.height).__eq__, + description="the window at its own size", + ) + assert window_position(screen) == (HAND_X, HAND_Y) + + screen.scenario(a_size_between_the_offered_ones, keep_another_size, discard_it).run() diff --git a/tests/unit/sampletones_application/coordinators/test_display.py b/tests/unit/sampletones_application/coordinators/test_display.py index 6c2e05247..7394d5914 100644 --- a/tests/unit/sampletones_application/coordinators/test_display.py +++ b/tests/unit/sampletones_application/coordinators/test_display.py @@ -22,6 +22,7 @@ WIDESCREEN = Resolution(width=1600, height=900) DEFAULT_RESOLUTION = Resolution(width=1280, height=800) +HAND_SIZED = Resolution(width=1300, height=820) COUNTDOWN_SECONDS = 10.0 @@ -418,9 +419,10 @@ def test_discarding_puts_back_the_window_mode_the_dialog_opened_with(self, harne harness.cancel() harness.dialogs.confirm() - assert harness.viewport.calls[-1] == ( - "resolution", - (DEFAULT_RESOLUTION.width, DEFAULT_RESOLUTION.height), + assert harness.viewport.placement == WindowPlacement( + x=OPENING_X, + y=OPENING_Y, + resolution=DEFAULT_RESOLUTION, ) def test_discarding_writes_nothing_to_the_session(self, harness: Harness) -> None: @@ -431,6 +433,63 @@ def test_discarding_writes_nothing_to_the_session(self, harness: Harness) -> Non assert harness.session.writes == [] +class TestCancelingAHandSizedWindow: + """A window sitting at a size of its own goes back to that exact size and place on Cancel. + + The dialog shows the offered size nearest the window's own, and a discard puts back the opening + place and size the run ending while the dialog stands puts back. A dialog opened in fullscreen goes + back to fullscreen and is placed nowhere. + """ + + OPENING_PLACEMENT = WindowPlacement(x=OPENING_X, y=OPENING_Y, resolution=HAND_SIZED) + + @pytest.fixture(name="hand_sized") + def hand_sized_fixture(self) -> Harness: + """The dialog opened over a window the user sized by hand, between the sizes on offer.""" + harness = Harness() + harness.viewport.resolution = (HAND_SIZED.width, HAND_SIZED.height) + harness.open() + return harness + + def test_the_dialog_shows_the_offered_size_nearest_the_window(self, hand_sized: Harness) -> None: + assert hand_sized.settings.window.resolution == DEFAULT_RESOLUTION + + def test_discarding_a_new_size_puts_back_the_window_s_own(self, hand_sized: Harness) -> None: + hand_sized.change(hand_sized.settings.with_window(hand_sized.settings.window.with_resolution(WIDESCREEN))) + hand_sized.keep() + hand_sized.cancel() + hand_sized.dialogs.confirm() + + assert hand_sized.viewport.placement == self.OPENING_PLACEMENT + assert not hand_sized.window.visible + + def test_canceling_an_untouched_dialog_leaves_the_window_s_own_size(self, hand_sized: Harness) -> None: + hand_sized.cancel() + + assert hand_sized.viewport.placement == self.OPENING_PLACEMENT + + def test_discarding_fullscreen_puts_back_the_window_s_own_place(self, hand_sized: Harness) -> None: + hand_sized.change(hand_sized.settings.with_window(hand_sized.settings.window.with_fullscreen(True))) + hand_sized.keep() + hand_sized.cancel() + hand_sized.dialogs.confirm() + + assert hand_sized.viewport.fullscreen_toggles == 2 + assert hand_sized.viewport.placement == self.OPENING_PLACEMENT + + def test_a_dialog_opened_in_fullscreen_goes_back_to_fullscreen(self) -> None: + harness = Harness() + harness.session.fullscreen = True + harness.open() + harness.change(harness.settings.with_window(harness.settings.window.with_fullscreen(False))) + harness.keep() + harness.cancel() + harness.dialogs.confirm() + + assert harness.viewport.fullscreen_toggles == 2 + assert all(name != "place" for name, _ in harness.viewport.calls) + + class TestCountdown: def test_a_window_mode_change_starts_the_clock(self, harness: Harness) -> None: harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True))) @@ -534,10 +593,11 @@ def test_keeping_stops_the_clock_and_leaves_the_change_standing(self, harness: H def test_a_kept_change_is_still_undone_by_canceling(self, harness: Harness) -> None: harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True))) harness.keep() + made_before_canceling = len(harness.viewport.calls) harness.cancel() harness.dialogs.confirm() - assert harness.viewport.calls[-1] == ("borderless", False) + assert ("borderless", False) in harness.viewport.calls[made_before_canceling:] def test_reverting_by_hand_puts_the_window_mode_back_at_once(self, harness: Harness) -> None: harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN))) From 66f004ee90a89599a2d169f28ea47b229aa5bc55 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 11:47:13 +0200 Subject: [PATCH 41/84] Added: Alt+1 to Alt+4 as channel keys that answer while the digits type notes or values --- docs/guide/reconstruction.md | 2 + docs/guide/sequencer.md | 3 +- .../keybindings/default.yaml | 8 +-- src/sampletones_config/keybindings/macos.yaml | 8 +-- .../shortcuts/test_switching_views.py | 18 +----- .../sequencer/tracker/test_channel_keys.py | 60 +++++++++++++++++++ tests/suite/screens/steps/sequencer.py | 17 +++++- 7 files changed, 91 insertions(+), 25 deletions(-) create mode 100644 tests/screens/sequencer/tracker/test_channel_keys.py diff --git a/docs/guide/reconstruction.md b/docs/guide/reconstruction.md index 7bd0c4a5f..3df2110de 100644 --- a/docs/guide/reconstruction.md +++ b/docs/guide/reconstruction.md @@ -25,6 +25,8 @@ compare the two. Its **NES frequency** field retimes the reconstruction: type a rate. It is also locked for a reconstruction whose file is gone, until you save it to a file. The **Waveform** card has a checkbox for each channel, and keys `1` to `4` switch the same checkboxes. +While an instrument is open, `2` and `3` play notes, so use `Alt+1` to `Alt+4`, which switch the +checkboxes at any time. Click the waveform to play from that point. While playback is paused, a click moves the playback position. Drag the waveform to move the view, and double-click to fit the view. Scroll to zoom, hold **Alt** and diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 64bdc1733..1a853c26b 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -144,7 +144,8 @@ it. The channel names in the **Order** grid work the same way. **Playback ▸ Channels** shows which channels play. **Unmute all channels** unmutes all four. Keys `1` to `4` mute and unmute a channel when the cursor is outside the grids. Inside the grids, the -digit keys type values. +digit keys type values, and `Alt+1` to `Alt+4` mute and unmute the channels instead. `Alt+1` to `Alt+4` +work outside the grids too. Muting changes only what you hear. Saving, exporting, rendering and undo use every channel. Opening, creating or closing a project unmutes all channels. diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 6d2ae2b97..32522b99c 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -50,10 +50,10 @@ bindings: FollowPatterns: {combination: "Ctrl+Shift+F"} FollowOff: {combination: "Ctrl+Alt+F"} ToggleLoopSong: {combination: "Ctrl+L"} - ToggleChannelPulse1: {combination: "1"} - ToggleChannelPulse2: {combination: "2"} - ToggleChannelTriangle: {combination: "3"} - ToggleChannelNoise: {combination: "4"} + ToggleChannelPulse1: {combination: "1", aliases: ["Alt+1"]} + ToggleChannelPulse2: {combination: "2", aliases: ["Alt+2"]} + ToggleChannelTriangle: {combination: "3", aliases: ["Alt+3"]} + ToggleChannelNoise: {combination: "4", aliases: ["Alt+4"]} UnmuteAllChannels: {combination: ~} # view diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index a598c0f1e..35ba8a3d1 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -50,10 +50,10 @@ bindings: FollowPatterns: {combination: "Cmd+Shift+F"} FollowOff: {combination: "Cmd+Alt+F"} ToggleLoopSong: {combination: "Cmd+L"} - ToggleChannelPulse1: {combination: "1"} - ToggleChannelPulse2: {combination: "2"} - ToggleChannelTriangle: {combination: "3"} - ToggleChannelNoise: {combination: "4"} + ToggleChannelPulse1: {combination: "1", aliases: ["Alt+1"]} + ToggleChannelPulse2: {combination: "2", aliases: ["Alt+2"]} + ToggleChannelTriangle: {combination: "3", aliases: ["Alt+3"]} + ToggleChannelNoise: {combination: "4", aliases: ["Alt+4"]} UnmuteAllChannels: {combination: ~} # view diff --git a/tests/screens/interface/shortcuts/test_switching_views.py b/tests/screens/interface/shortcuts/test_switching_views.py index 2f70a92b0..f6e233b33 100644 --- a/tests/screens/interface/shortcuts/test_switching_views.py +++ b/tests/screens/interface/shortcuts/test_switching_views.py @@ -8,6 +8,7 @@ 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.steps.sequencer import channels_sounding, checked from tests.suite.screens.vocabulary.playback import PAUSE, PLAY from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION @@ -40,19 +41,6 @@ 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] @@ -87,12 +75,12 @@ def channels(screen: Screen) -> None: 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") + screen.expect(partial(channels_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") + screen.expect(partial(channels_sounding, screen), all, description="every channel sounding again") def switches(screen: Screen) -> None: for shortcut_id, group, item in CHECKED_SHORTCUTS: diff --git a/tests/screens/sequencer/tracker/test_channel_keys.py b/tests/screens/sequencer/tracker/test_channel_keys.py new file mode 100644 index 000000000..4266dcc0e --- /dev/null +++ b/tests/screens/sequencer/tracker/test_channel_keys.py @@ -0,0 +1,60 @@ +from functools import partial +from typing import Final, List + +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.screens.sequencer.tracker.steps import play_a_note +from tests.suite.screens.dearpygui.keys import IMGUI_DIGIT_ZERO, IMGUI_LEFT_ALT +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import channels_sounding, leave_letting_the_project_go, on_the_sequencer +from tests.suite.screens.worlds.songs import PAD_ROW + +DIGIT_TWO: Final[int] = IMGUI_DIGIT_ZERO + 2 +EVERY_CHANNEL_SOUNDING: Final[List[bool]] = [True, True, True, True] +PULSE_TWO_MUTED: Final[List[bool]] = [True, False, True, True] + + +class TestTheChannelKeysBesideTheNotes: + """With the cursor in a pitch column, 2 types the note it names, and Alt+2 mutes Pulse 2 all the same. + + A note is typed with 2 and the mix stays whole. Alt+2 then mutes Pulse 2 and leaves the note, and Alt+2 + again brings it back. + """ + + def test_two_types_a_note_and_alt_two_mutes_pulse_two(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + typed: List[str] = [] + + def two_types_a_note(screen: Screen) -> None: + on_the_sequencer(screen) + before = tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) + + play_a_note(screen, PAD_ROW, ChannelName.PULSE2, DIGIT_TWO) + + typed.append(tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE)) + assert typed[0] != before + assert channels_sounding(screen) == EVERY_CHANNEL_SOUNDING + + def alt_two_mutes_pulse_two(screen: Screen) -> None: + screen.hand.press_key(DIGIT_TWO, modifiers=[IMGUI_LEFT_ALT]) + + screen.expect(partial(channels_sounding, screen), PULSE_TWO_MUTED.__eq__, description="Pulse 2 muted") + screen.frames(TYPING_FRAMES) + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == typed[0] + + def alt_two_brings_it_back(screen: Screen) -> None: + screen.hand.press_key(DIGIT_TWO, modifiers=[IMGUI_LEFT_ALT]) + + screen.expect( + partial(channels_sounding, screen), + EVERY_CHANNEL_SOUNDING.__eq__, + description="every channel sounding again", + ) + + screen.scenario( + two_types_a_note, + alt_two_mutes_pulse_two, + alt_two_brings_it_back, + leave_letting_the_project_go, + ).run() diff --git a/tests/suite/screens/steps/sequencer.py b/tests/suite/screens/steps/sequencer.py index ce6363885..e5af019f7 100644 --- a/tests/suite/screens/steps/sequencer.py +++ b/tests/suite/screens/steps/sequencer.py @@ -1,8 +1,10 @@ from functools import partial -from typing import Final +from typing import Final, List +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.suite.screens.dearpygui.items.types import Item from tests.suite.screens.screen import Screen @@ -48,3 +50,16 @@ 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") + + +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 channels_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() + ] From fce61bf48e60d1ef8e7581312ba0b3c148447efe Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 11:51:43 +0200 Subject: [PATCH 42/84] Fixed: the Display countdown leaving a hand-sized window at the nearest offered size, as Cancel did --- .../coordinators/display.py | 72 +++++++++++-------- tests/screens/interface/display/constants.py | 5 ++ tests/screens/interface/display/steps.py | 32 ++++++++- .../interface/display/test_countdown.py | 71 +++++++++++++++++- .../interface/display/test_discard_prompt.py | 31 ++------ .../coordinators/test_display.py | 31 +++++++- 6 files changed, 181 insertions(+), 61 deletions(-) diff --git a/src/sampletones_application/coordinators/display.py b/src/sampletones_application/coordinators/display.py index 1fe61e361..7a035c30a 100644 --- a/src/sampletones_application/coordinators/display.py +++ b/src/sampletones_application/coordinators/display.py @@ -1,4 +1,5 @@ import math +from dataclasses import dataclass from typing import Optional from sampletones_application.categories.manager import LanguageManager @@ -23,6 +24,18 @@ from sampletones_shared.display import Resolution +@dataclass(frozen=True) +class ReadableWindow: + """A window mode last seen readable, with the place and size the window stood at under it. + + A window sitting at a size of its own reads as the offered size nearest it, so the placement is + what carries the size the window had. + """ + + mode: WindowMode + placement: WindowPlacement + + class DisplayCoordinator: """Owns the display settings: the options offered, the live application of a change, and the countdown that returns a window mode nobody confirmed. @@ -35,8 +48,9 @@ class DisplayCoordinator: Changing the window's size, its frame, or fullscreen can leave the window unreadable, so each of those arms a countdown over the dialog: keeping it disarms the clock and leaves the change - pending, and letting the clock run out brings the last confirmed window mode back while every - other pending edit stays. + pending, and letting the clock run out puts the window back as it stood under the last confirmed + mode, at its own place and size, while every other pending edit stays. Cancel and the clock put a + window back the same way. """ def __init__( @@ -69,7 +83,7 @@ def __init__( self._settings: Optional[DisplaySettings] = None self._snapshot: Optional[DisplaySettings] = None self._opening_placement: Optional[WindowPlacement] = None - self._armed: Optional[WindowMode] = None + self._armed: Optional[ReadableWindow] = None self._remaining: float = 0.0 self._window.on_settings_changed = self._change @@ -114,17 +128,21 @@ def tick(self, delta_time: float) -> None: self._countdown.set_remaining(self._displayed_seconds()) def _change(self, settings: DisplaySettings) -> None: - """Puts an edit on screen, arming the countdown when it changed the window mode.""" + """Puts an edit on screen, arming the countdown when it changed the window mode. + + The window is read before the change reaches it, which is the window the countdown puts back. + """ previous = self._require_settings() + readable = ReadableWindow(mode=previous.window, placement=self._viewport_manager.placement) self._settings = settings self._apply(previous, settings) if settings.window != previous.window: - self._arm(previous.window) + self._arm(readable) self._window.update_view(self._view_model(settings)) - def _arm(self, restorable: WindowMode) -> None: - """Starts the countdown that brings ``restorable`` back unless the change is confirmed. + def _arm(self, restorable: ReadableWindow) -> None: + """Starts the countdown that puts ``restorable`` back unless the change is confirmed. A countdown already running keeps the mode it was going to restore and starts its count again on the prompt already on screen, so a run of unconfirmed changes still returns to @@ -154,19 +172,27 @@ def _keep(self) -> None: self._disarm() def _revert(self) -> None: - """Brings the last confirmed window mode back, leaving every other pending edit in place.""" + """Puts the window back as it stood under the last confirmed mode, leaving every other pending edit.""" restorable = self._armed self._disarm() if restorable is None: return - self._restore(self._require_settings().with_window(restorable)) + self._put_back(self._require_settings().with_window(restorable.mode), restorable.placement) - def _restore(self, settings: DisplaySettings) -> None: - """Puts ``settings`` on screen as the state in force, without arming a countdown.""" + def _put_back(self, settings: DisplaySettings, placement: WindowPlacement) -> None: + """Puts ``settings`` on screen as the state in force, and a windowed window back at ``placement``. + + A place and a size set directly reach DearPyGui's reading of the window at once, while a + fullscreen change reaches it on a drawn frame alone, so a windowed window is placed after its + settings, and a record of the window taken right after names that place and size. + """ previous = self._require_settings() self._settings = settings self._apply(previous, settings) + if not settings.window.fullscreen: + self._viewport_manager.place(placement) + self._window.update_view(self._view_model(settings)) def _commit(self) -> None: @@ -203,29 +229,15 @@ def _ask_to_discard(self) -> None: ) def _discard(self) -> None: - """Puts back the display the dialog opened with and closes it.""" + """Puts back the display the dialog opened with, a windowed window at its own place and size, and + closes it.""" self._disarm() - self._restore_opening_display() - self._close() - - def _restore_opening_display(self) -> None: - """Re-applies the settings the dialog opened with, and puts a windowed window back at its own place - and size. - - A window sitting at a size of its own opens the dialog on the offered size nearest it, and the - placement taken at opening is what carries the size the window had. A place and a size set - directly reach DearPyGui's reading of the window at once, while a fullscreen change reaches it - on a drawn frame alone, so a window that opened windowed is placed after its settings, and a - record of the window taken right after names its opening place and size. - """ snapshot = self._snapshot placement = self._opening_placement - if snapshot is None or placement is None: - return + if snapshot is not None and placement is not None: + self._put_back(snapshot, placement) - self._apply(self._require_settings(), snapshot) - if not snapshot.window.fullscreen: - self._viewport_manager.place(placement) + self._close() def _close(self) -> None: self._settings = None diff --git a/tests/screens/interface/display/constants.py b/tests/screens/interface/display/constants.py index 0055f2b9c..ea4888aed 100644 --- a/tests/screens/interface/display/constants.py +++ b/tests/screens/interface/display/constants.py @@ -1,3 +1,8 @@ from typing import Final +from sampletones_shared.display import Resolution + SIZE_SEPARATOR: Final[str] = "x" +HAND_SIZE: Final[Resolution] = Resolution(width=1300, height=820) +HAND_X: Final[int] = 100 +HAND_Y: Final[int] = 60 diff --git a/tests/screens/interface/display/steps.py b/tests/screens/interface/display/steps.py index 85581833c..c43349bfa 100644 --- a/tests/screens/interface/display/steps.py +++ b/tests/screens/interface/display/steps.py @@ -1,10 +1,13 @@ import operator from typing import Tuple +from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.config.session.state.window import ViewportState from sampletones_shared.display import Resolution -from tests.screens.interface.display.constants import SIZE_SEPARATOR +from tests.screens.interface.display.constants import HAND_SIZE, HAND_X, HAND_Y, SIZE_SEPARATOR from tests.suite.screens.dearpygui.items.viewport import read_viewport, read_viewport_decorated from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World def size_named(label: str) -> Resolution: @@ -50,3 +53,30 @@ def kept(screen: Screen) -> None: screen.expect(settings.countdown_shown, bool, description="the countdown") settings.keep() screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") + + +def hand_sized_world() -> World: + """A home whose window the user sized by hand, between the sizes Display settings offers.""" + return World( + state=ApplicationState( + viewport=ViewportState( + width=HAND_SIZE.width, + height=HAND_SIZE.height, + x=HAND_X, + y=HAND_Y, + ) + ), + application_config=None, + config=None, + files=(), + ) + + +def at_its_own_size_and_place(screen: Screen) -> None: + """Waits until the window stands at the size it was sized to by hand, and checks its place.""" + screen.expect( + lambda: window_size(screen), + (HAND_SIZE.width, HAND_SIZE.height).__eq__, + description="the window at its own size", + ) + assert window_position(screen) == (HAND_X, HAND_Y) diff --git a/tests/screens/interface/display/test_countdown.py b/tests/screens/interface/display/test_countdown.py index 38cc004f3..21a57240d 100644 --- a/tests/screens/interface/display/test_countdown.py +++ b/tests/screens/interface/display/test_countdown.py @@ -1,8 +1,20 @@ import operator from typing import Final -from tests.screens.interface.display.steps import framed, kept, open_display_settings +import pytest + +from tests.screens.interface.display.steps import ( + another_size, + at_its_own_size_and_place, + framed, + hand_sized_world, + kept, + open_display_settings, + size_named, + window_size, +) from tests.suite.screens.screen import Screen +from tests.suite.screens.worlds.home import World COUNTDOWN_LIMIT_SECONDS: Final[float] = 20.0 SETTLING_FRAMES: Final[int] = 10 @@ -61,3 +73,60 @@ def keep_keeps_it_until_cancel(screen: Screen) -> None: 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 TestTheCountdownOnAHandSizedWindow: + """Revert and the count running out put a window the user sized by hand back at its own size and place, + as Cancel does. + + The window opens between the sizes Display settings offers. The scenario picks another size and presses + Revert, then picks it again and lets the countdown run out, expecting the window back where it opened + each time, and Cancel then closes the dialog with the window still there. + """ + + @pytest.fixture + def world(self) -> World: + """A home whose window the user sized by hand, between the sizes Display settings offers.""" + return hand_sized_world() + + def test_revert_and_running_out_put_back_the_window_s_own_size(self, screen: Screen) -> None: + settings = screen.display_settings + + def another_size_on_screen(screen: Screen) -> None: + label = another_size(screen) + settings.choose_resolution(label) + screen.expect(settings.countdown_shown, bool, description="the countdown") + screen.expect( + lambda: window_size(screen), + (size_named(label).width, size_named(label).height).__eq__, + description="the window at the size picked", + ) + + def revert_puts_it_back(screen: Screen) -> None: + open_display_settings(screen) + another_size_on_screen(screen) + + settings.revert() + + screen.expect(settings.countdown_shown, operator.not_, description="the countdown gone") + at_its_own_size_and_place(screen) + + def running_out_puts_it_back(screen: Screen) -> None: + another_size_on_screen(screen) + + screen.bridge.expect( + settings.countdown_shown, + operator.not_, + description="the countdown run out", + timeout=COUNTDOWN_LIMIT_SECONDS, + ) + + at_its_own_size_and_place(screen) + + def cancel_leaves_it_there(screen: Screen) -> None: + settings.cancel() + + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + at_its_own_size_and_place(screen) + + screen.scenario(revert_puts_it_back, running_out_puts_it_back, cancel_leaves_it_there).run() diff --git a/tests/screens/interface/display/test_discard_prompt.py b/tests/screens/interface/display/test_discard_prompt.py index 651a3c482..3cde14530 100644 --- a/tests/screens/interface/display/test_discard_prompt.py +++ b/tests/screens/interface/display/test_discard_prompt.py @@ -3,12 +3,12 @@ import pytest -from sampletones_application.config.session.state.state import ApplicationState -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.constants import HAND_SIZE, HAND_X, HAND_Y from tests.screens.interface.display.steps import ( another_size, + at_its_own_size_and_place, + hand_sized_world, kept, open_display_settings, size_named, @@ -18,10 +18,6 @@ from tests.suite.screens.screen import Screen from tests.suite.screens.worlds.home import World -HAND_SIZE: Final[Resolution] = Resolution(width=1300, height=820) -HAND_X: Final[int] = 100 -HAND_Y: Final[int] = 60 - class TestDiscardingADisplayChange: """Cancelling Display settings with a change pending asks before the change is thrown away. @@ -110,19 +106,7 @@ class TestDiscardingOnAHandSizedWindow: @pytest.fixture def world(self) -> World: """A home whose window the user sized by hand, between the sizes Display settings offers.""" - return World( - state=ApplicationState( - viewport=ViewportState( - width=HAND_SIZE.width, - height=HAND_SIZE.height, - x=HAND_X, - y=HAND_Y, - ) - ), - application_config=None, - config=None, - files=(), - ) + return hand_sized_world() def test_discarding_puts_back_the_window_s_own_size(self, screen: Screen) -> None: """The window stands at its own size and place once the new size is discarded.""" @@ -157,11 +141,6 @@ def discard_it(screen: Screen) -> None: prompt.confirm() screen.expect(settings.is_shown, operator.not_, description="the dialog closed") - screen.expect( - lambda: window_size(screen), - (HAND_SIZE.width, HAND_SIZE.height).__eq__, - description="the window at its own size", - ) - assert window_position(screen) == (HAND_X, HAND_Y) + at_its_own_size_and_place(screen) screen.scenario(a_size_between_the_offered_ones, keep_another_size, discard_it).run() diff --git a/tests/unit/sampletones_application/coordinators/test_display.py b/tests/unit/sampletones_application/coordinators/test_display.py index 7394d5914..b4712890b 100644 --- a/tests/unit/sampletones_application/coordinators/test_display.py +++ b/tests/unit/sampletones_application/coordinators/test_display.py @@ -434,11 +434,13 @@ def test_discarding_writes_nothing_to_the_session(self, harness: Harness) -> Non class TestCancelingAHandSizedWindow: - """A window sitting at a size of its own goes back to that exact size and place on Cancel. + """A window sitting at a size of its own goes back to that exact size and place on Cancel and when the + countdown puts a change back. The dialog shows the offered size nearest the window's own, and a discard puts back the opening - place and size the run ending while the dialog stands puts back. A dialog opened in fullscreen goes - back to fullscreen and is placed nowhere. + place and size the run ending while the dialog stands puts back. The countdown puts back the window + as it stood before the change it guards. A dialog opened in fullscreen goes back to fullscreen and is + placed nowhere. """ OPENING_PLACEMENT = WindowPlacement(x=OPENING_X, y=OPENING_Y, resolution=HAND_SIZED) @@ -477,6 +479,29 @@ def test_discarding_fullscreen_puts_back_the_window_s_own_place(self, hand_sized assert hand_sized.viewport.fullscreen_toggles == 2 assert hand_sized.viewport.placement == self.OPENING_PLACEMENT + def test_the_clock_running_out_puts_back_the_window_s_own(self, hand_sized: Harness) -> None: + hand_sized.change(hand_sized.settings.with_window(hand_sized.settings.window.with_resolution(WIDESCREEN))) + hand_sized.elapse(COUNTDOWN_SECONDS) + + assert hand_sized.viewport.placement == self.OPENING_PLACEMENT + assert hand_sized.window.visible + + def test_reverting_puts_back_the_window_s_own(self, hand_sized: Harness) -> None: + hand_sized.change(hand_sized.settings.with_window(hand_sized.settings.window.with_resolution(WIDESCREEN))) + hand_sized.revert() + + assert hand_sized.viewport.placement == self.OPENING_PLACEMENT + + def test_a_kept_size_is_what_a_later_clock_puts_back(self, hand_sized: Harness) -> None: + hand_sized.change(hand_sized.settings.with_window(hand_sized.settings.window.with_resolution(WIDESCREEN))) + hand_sized.keep() + kept = hand_sized.viewport.placement + hand_sized.change(hand_sized.settings.with_window(hand_sized.settings.window.with_borderless(True))) + hand_sized.elapse(COUNTDOWN_SECONDS) + + assert hand_sized.viewport.placement == kept + assert kept.resolution == WIDESCREEN + def test_a_dialog_opened_in_fullscreen_goes_back_to_fullscreen(self) -> None: harness = Harness() harness.session.fullscreen = True From ed2b5bf27690dd48608b85227a5a35ae231603c4 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 11:54:10 +0200 Subject: [PATCH 43/84] Removed: test helpers nothing calls, among them the screen tier's forgiving of known errors --- docs/development/application/screens.md | 4 +--- tests/suite/browser.py | 11 ---------- tests/suite/groove.py | 13 ----------- tests/suite/screens/screen.py | 9 -------- tests/suite/screens/views/browsers.py | 23 +------------------- tests/suite/screens/views/exports.py | 4 ---- tests/suite/screens/views/reconstructions.py | 4 ---- 7 files changed, 2 insertions(+), 66 deletions(-) diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index 616919eac..86fd92681 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -85,9 +85,7 @@ 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. 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. + 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. diff --git a/tests/suite/browser.py b/tests/suite/browser.py index 8263f4fe2..ab3ffcdc3 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -540,17 +540,6 @@ def set_row_expanded( panel._expansion.remember(panel._generate_node_tag(node), expanded=expanded) -def set_filter( - panel: GUITreePanel, - *, - favorites_only: bool, - query: str = "", -) -> None: - """States what the browser is now asked to show, as a change of the control or the search box.""" - panel._filter = TreeFilter(query=query, favorites_only=favorites_only) - panel._resolve_filter() - - def select_favorites(panel: GUITreePanel) -> None: """Switches the favorites mode on the way the reader's click does, and resolves the pass it starts. diff --git a/tests/suite/groove.py b/tests/suite/groove.py index a0860392a..bbb296acb 100644 --- a/tests/suite/groove.py +++ b/tests/suite/groove.py @@ -21,19 +21,6 @@ def bar_rows(meter: Meter, frames: int) -> Tuple[int, ...]: return tuple(starts) -def beat_spans(meter: Meter, frames: int) -> Tuple[Tuple[int, int], ...]: - """The first row and the row count of every beat of ``frames`` patterns.""" - spans: List[Tuple[int, int]] = [] - for frame in range(frames): - row = frame * meter.rows - for beats in meter.spans: - for beat in beats: - spans.append((row, beat)) - row += beat - - return tuple(spans) - - def bar_line_drift( ticks: Sequence[int], starts: Sequence[int], diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 2ba95e7ba..516bfe298 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -285,15 +285,6 @@ 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/views/browsers.py b/tests/suite/screens/views/browsers.py index 207572592..9baabba6d 100644 --- a/tests/suite/screens/views/browsers.py +++ b/tests/suite/screens/views/browsers.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, List, Optional +from typing import Callable, Optional import dearpygui.dearpygui as dpg @@ -36,18 +36,6 @@ 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 - again. - """ - - 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 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) @@ -118,12 +106,3 @@ def _node_matches(item: Item, matches: NodeTest) -> bool: 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/exports.py b/tests/suite/screens/views/exports.py index 1edf75a66..9c8e45b90 100644 --- a/tests/suite/screens/views/exports.py +++ b/tests/suite/screens/views/exports.py @@ -188,10 +188,6 @@ 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) diff --git a/tests/suite/screens/views/reconstructions.py b/tests/suite/screens/views/reconstructions.py index 4011394b6..70164cf54 100644 --- a/tests/suite/screens/views/reconstructions.py +++ b/tests/suite/screens/views/reconstructions.py @@ -130,10 +130,6 @@ 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: """Clicks the box of ``channel`` above the waveform, which flips its tick.""" self._hand.click(_channel_box(channel)) From ddcfc62f92b18c6158256d3b29e5e79f5ff86d1c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 11:55:10 +0200 Subject: [PATCH 44/84] Recorded: marking broken files in the browsers so fewer notices reach the reader --- docs/development/bugs-and-todos.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index a374dd846..0e229d463 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -50,6 +50,10 @@ dimension the import starts carrying. with no ceiling, and every gesture the list offers one row must reach each picked row. * Selection operations on a reconstruction * Reconstruction trimming +* Marking broken files where the browsers list them, so fewer notices reach the reader. A reconstruction + that fails to load, one whose recordings are missing and a recording that cannot be read could stand in a + warning or an error color. Today every attempt to open or play one raises its notice, once per attempt, + and a mark would tell the reader before they try. ### Features From f9ef08d5bf110e1feee27a81eb7cebb32083f354 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 11:59:44 +0200 Subject: [PATCH 45/84] Fixed: a refused stream leaving the Playback menu on Pause and raising its error off the render thread --- src/sampletones_application/application.py | 9 ++- src/sampletones_core/audio/manager.py | 6 +- .../application/refusing_output/__init__.py | 0 .../application/refusing_output/conftest.py | 9 +++ .../refusing_output/test_playing.py | 62 ++++++++++++++ tests/suite/screens/boundaries/audio.py | 17 +++- .../test_application_playback.py | 81 +++++++++++++++++++ .../sampletones_core/audio/test_manager.py | 18 +++++ 8 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 tests/screens/application/refusing_output/__init__.py create mode 100644 tests/screens/application/refusing_output/conftest.py create mode 100644 tests/screens/application/refusing_output/test_playing.py create mode 100644 tests/unit/sampletones_application/test_application_playback.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index f75df1c4f..3fe6ed5d8 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -160,6 +160,7 @@ from sampletones_application.utils.gui.palette.palette import PaletteBindings from sampletones_application.utils.gui.render_thread import ( claim_render_thread, + on_render_thread, release_render_thread, ) from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog @@ -1194,8 +1195,14 @@ def _handle_reconstruct_directory(self, directory_path: Path) -> None: self._main_tab.request_reconstruct_directory(directory_path) def _on_playback_error(self, exception: Exception) -> None: + """Reports a playback the device refused, on the render thread whichever thread heard of it. + + The device manager reports a stream that failed to open from the thread playing the audio, and + the report raises a dialog, so it crosses to the render thread the way every worker's result does. + """ logger.error_with_traceback(exception, "Playback error occurred") - self._playback_failures.present( + on_render_thread( + self._playback_failures.present, exception, message=self.language_manager["global.dialog.message.audio_playback_error"], ) diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index 652971962..dae3d5a2a 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -714,8 +714,9 @@ def _playback_worker( """ Playback thread worker function. - Opens an audio stream, runs the playback loop, and ensures cleanup. - Handles stream opening errors by invoking the error callback. + Opens an audio stream, runs the playback loop, and ensures cleanup. A stream the device + refuses to open leaves the playback idle, as a finished one does, before the error callback + reports it, so whoever follows the playback reads it stopped. Args: output: The device and rate in force when the playback was asked for. @@ -732,6 +733,7 @@ def _playback_worker( output_device_index=output.device_index, ) except OSError as exception: + self._reset(update=update) playback_error = PlaybackError(f"Failed to open audio stream: {exception}") self.call(self.on_playback_error, playback_error) return diff --git a/tests/screens/application/refusing_output/__init__.py b/tests/screens/application/refusing_output/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/application/refusing_output/conftest.py b/tests/screens/application/refusing_output/conftest.py new file mode 100644 index 000000000..56774c311 --- /dev/null +++ b/tests/screens/application/refusing_output/conftest.py @@ -0,0 +1,9 @@ +import pytest + +from tests.suite.screens.boundaries.audio import OutputDevice + + +@pytest.fixture +def output_device() -> OutputDevice: + """The machine offers a device that refuses every stream opened on it.""" + return OutputDevice.REFUSING diff --git a/tests/screens/application/refusing_output/test_playing.py b/tests/screens/application/refusing_output/test_playing.py new file mode 100644 index 000000000..0adcb5bbb --- /dev/null +++ b/tests/screens/application/refusing_output/test_playing.py @@ -0,0 +1,62 @@ +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 tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.audio import REFUSED_STREAM +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.reconstructions import expect_open +from tests.suite.screens.vocabulary.playback import PLAY +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, playing_world + +PLAYBACK_ERROR_MESSAGE: Final[str] = "global.dialog.message.audio_playback_error" + + +@pytest.fixture +def world() -> World: + """A home holding reconstructions that play.""" + return playing_world() + + +@pytest.fixture +def startup() -> Startup: + """The application opens on a reconstruction that plays.""" + return Startup(reconstruction=PLAYABLE_RECONSTRUCTION, project=None) + + +class TestPlayingOnADeviceThatRefusesTheStream: + """A stream the device refuses to open is reported as an error, and the application goes on answering. + + The device refuses on the thread that plays the audio, so the report crosses to the render thread + before the error window opens. Play brings up the error with its words; once OK dismisses it, the + Playback menu offers Play and a second Play reports the refusal again. + """ + + def test_play_reports_the_refusal_each_time(self, screen: Screen) -> None: + notice = screen.error_notice + + def play_reports_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + screen.expect(notice.is_shown, bool, description="the error about the refused stream") + screen.claim_error(REFUSED_STREAM) + assert screen.words(PLAYBACK_ERROR_MESSAGE) in notice.words() + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the error dismissed") + screen.expect( + screen.sequencer.playback.play_entry, + screen.words(PLAY).__eq__, + description="the Playback menu offering Play", + ) + + def open_it(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + expect_open(screen, PLAYABLE_RECONSTRUCTION) + + screen.scenario(open_it, play_reports_it, play_reports_it).run() diff --git a/tests/suite/screens/boundaries/audio.py b/tests/suite/screens/boundaries/audio.py index 3550e12eb..7e2255cc0 100644 --- a/tests/suite/screens/boundaries/audio.py +++ b/tests/suite/screens/boundaries/audio.py @@ -13,12 +13,15 @@ ALSA_CONFIGURATION_VARIABLE: Final[str] = "ALSA_CONFIG_PATH" DEVICE_BUFFER_SECONDS: Final[float] = 0.05 SILENT_DEFAULT_DEVICE: Final[str] = "pcm.!default {\n type null\n}\n" +REFUSED_STREAM: Final[str] = "A screen scenario's device refuses every stream" class OutputDevice(StrEnum): - """The output a scenario's machine offers: a device that plays into silence, or none at all.""" + """The output a scenario's machine offers: a device that plays into silence, one that refuses every + stream, or none at all.""" SILENT = "silent" + REFUSING = "refusing" NONE = "none" @@ -148,10 +151,22 @@ def provide_output_device( match device: case OutputDevice.SILENT: SilentOutputDevice().install(home, monkeypatch) + case OutputDevice.REFUSING: + SilentOutputDevice().install(home, monkeypatch) + _refusing_output_device(monkeypatch) case OutputDevice.NONE: _no_output_device(monkeypatch) +def _refusing_output_device(monkeypatch: pytest.MonkeyPatch) -> None: + """Offers the silent device and refuses every stream opened on it, as a device another program holds.""" + monkeypatch.setattr(pyaudio.PyAudio, "open", _refused_stream) + + +def _refused_stream(_: pyaudio.PyAudio, **__: object) -> pyaudio.PyAudio.Stream: + raise OSError(REFUSED_STREAM) + + 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) diff --git a/tests/unit/sampletones_application/test_application_playback.py b/tests/unit/sampletones_application/test_application_playback.py new file mode 100644 index 000000000..9df764804 --- /dev/null +++ b/tests/unit/sampletones_application/test_application_playback.py @@ -0,0 +1,81 @@ +import threading +from typing import Final, Iterator, List, Optional, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.application import Application +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.gui.render_thread import claim_render_thread, release_render_thread +from sampletones_shared.exceptions import PlaybackError +from tests.suite.language import FakeLanguageManager + +FAILURE_MESSAGE_KEY: Final[str] = "global.dialog.message.audio_playback_error" + + +class _PresenterRecorder: + """Notes the thread each failure is presented on, which is where its dialog is built.""" + + def __init__(self) -> None: + self.presented: List[Tuple[int, Exception, Optional[str]]] = [] + + def present(self, exception: Exception, *, message: Optional[str]) -> None: + self.presented.append((threading.get_ident(), exception, message)) + + +@pytest.fixture(name="presenter") +def presenter_fixture() -> _PresenterRecorder: + return _PresenterRecorder() + + +@pytest.fixture(name="application") +def application_fixture(presenter: _PresenterRecorder) -> Application: + application = Application.__new__(Application) + application._playback_failures = MagicMock(spec=PlaybackFailurePresenter) + application._playback_failures.present.side_effect = presenter.present + application.language_manager = FakeLanguageManager() + return application + + +@pytest.fixture(name="drawing") +def drawing_fixture() -> Iterator[None]: + """A run drawing its frames on this thread, over a queue live enough to drain what reaches it.""" + CallbackQueue.start() + claim_render_thread() + yield + release_render_thread() + CallbackQueue.stop() + CallbackQueue.start() + + +class TestAPlaybackTheDeviceRefused: + """A stream that fails to open is reported where the interface is drawn, whichever thread heard of it.""" + + @pytest.mark.usefixtures("drawing") + def test_a_report_from_the_playing_thread_waits_for_the_render_thread( + self, + application: Application, + presenter: _PresenterRecorder, + ) -> None: + failure = PlaybackError("Failed to open audio stream") + worker = threading.Thread(target=application._on_playback_error, args=(failure,)) + worker.start() + worker.join() + + assert presenter.presented == [] + CallbackQueue.process(1.0) + + assert presenter.presented == [(threading.get_ident(), failure, FAILURE_MESSAGE_KEY)] + + @pytest.mark.usefixtures("drawing") + def test_a_report_on_the_render_thread_is_presented_at_once( + self, + application: Application, + presenter: _PresenterRecorder, + ) -> None: + failure = PlaybackError("Failed to open audio stream") + + application._on_playback_error(failure) + + assert presenter.presented == [(threading.get_ident(), failure, FAILURE_MESSAGE_KEY)] diff --git a/tests/unit/sampletones_core/audio/test_manager.py b/tests/unit/sampletones_core/audio/test_manager.py index 7f756245a..c9607a377 100644 --- a/tests/unit/sampletones_core/audio/test_manager.py +++ b/tests/unit/sampletones_core/audio/test_manager.py @@ -200,6 +200,24 @@ def thread(**_kwargs: object) -> MagicMock: assert positions_at_thread_start == [test_case.expected] +class TestAStreamTheDeviceRefuses: + """A stream the device refuses to open leaves the playback idle before the refusal is reported.""" + + def test_the_playback_reads_stopped_when_the_refusal_is_reported(self) -> None: + manager = _manager() + manager._pyaudio.open.side_effect = OSError("the device is busy") + manager._position_callback = MagicMock() + playing_when_reported: List[bool] = [] + manager.on_playback_error = lambda _error: playing_when_reported.append(manager.is_playing()) + manager._playing = True + manager._audio_data = np.zeros(TestPlaybackStart.AUDIO_LENGTH, dtype=np.float32) + + manager._playback_worker(output=manager.require_output(), update=True) + + assert playing_when_reported == [False] + manager._position_callback.assert_called_once_with(0) + + class TestSeekingAPlayback(BaseTestSuite): """A seek moves the playback of the owner asking for it, clamped to the audio, under one lock.""" From 07b0dbfdfcecb76066c57ceb4a291e6249c0c053 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 12:02:01 +0200 Subject: [PATCH 46/84] Moved: screen scenario homes into a temporary folder, keeping a failed one's home with its artifacts --- docs/development/application/screens.md | 9 ++++--- tests/suite/screens/environment.py | 34 ++++++++++++++++++++----- tests/suite/screens/paths.py | 1 + tests/suite/screens/plugin/constants.py | 2 ++ tests/suite/screens/plugin/hooks.py | 31 +++++++++++++++++++--- 5 files changed, 63 insertions(+), 14 deletions(-) diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index 86fd92681..e665063cf 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -298,9 +298,12 @@ To follow a scenario as it plays, draw on Xephyr, which opens its screen as a wi 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. +A scenario's home is scratch: it is built from the scenario's world in a temporary folder made for each +worker, whose path holds no hidden folder, so the application's browsers reach it from a worktree under +`.worktrees/` too, and it goes once the scenario ends. What a run keeps lies under `build/screens/`, in a +folder named after the test: the reports, and when the scenario failed, a screenshot of the last frame and +a copy of the home it left. `screen.capture` keeps a picture as evidence of a look, for a pull request +rather than an assertion. ## Who governs what diff --git a/tests/suite/screens/environment.py b/tests/suite/screens/environment.py index b3524a208..82c1f4e67 100644 --- a/tests/suite/screens/environment.py +++ b/tests/suite/screens/environment.py @@ -27,7 +27,12 @@ def display_backend(environment: Mapping[str, str]) -> DisplayBackend: @dataclass(frozen=True) class ScenarioFolders: - """Where one scenario's run keeps its files: the home its application lives in, and its reports. + """Where one scenario's run keeps its files: the home its application lives in, and what the run keeps. + + The home is scratch, built afresh from the scenario's world in a temporary folder whose path holds no + hidden folder, so the application's browsers reach it from any checkout. What the run keeps lies under + the run's artifacts in the checkout: the reports, a screenshot of a failure, and a copy of the home a + failed scenario left. Attributes: root: The scenario's folder under the run's artifacts, kept after the run for a reader. @@ -40,20 +45,35 @@ class ScenarioFolders: 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) + def of(cls, nodeid: str, homes: Path) -> "ScenarioFolders": + """The folders of the scenario ``nodeid`` names, under a name a file system accepts. + + Args: + nodeid: The scenario's pytest node id. + homes: The temporary folder holding the homes of this worker's scenarios. + """ + name = UNSAFE_CHARACTERS.sub("_", nodeid) + root = ARTIFACTS_DIRECTORY / name return cls( root=root, - home=root / HOME_FOLDER, + home=homes / name / 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.""" + """Clears what an earlier run of the scenario left, and lays out an empty home and artifacts folder.""" shutil.rmtree(self.root, ignore_errors=True) + shutil.rmtree(self.home, ignore_errors=True) + self.root.mkdir(parents=True) self.home.mkdir(parents=True) + def finish(self, *, failed: bool) -> None: + """Lets the scratch home go, keeping a copy among the artifacts where the scenario failed.""" + if failed: + shutil.copytree(self.home, self.root / HOME_FOLDER, symlinks=True) + + shutil.rmtree(self.home.parent, ignore_errors=True) + def child_environment( base: Mapping[str, str], @@ -63,7 +83,7 @@ def child_environment( ) -> 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 + The home and every XDG directory lie inside the scenario's home, 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. diff --git a/tests/suite/screens/paths.py b/tests/suite/screens/paths.py index 558ba4a7c..acd102aea 100644 --- a/tests/suite/screens/paths.py +++ b/tests/suite/screens/paths.py @@ -8,6 +8,7 @@ DEARPYGUI_LAYER_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "tests" / "suite" / "screens" / "dearpygui" ARTIFACTS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "build" / "screens" HOME_FOLDER: Final[str] = "home" +HOMES_PREFIX: Final[str] = "sampletones-screens-" 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/constants.py b/tests/suite/screens/plugin/constants.py index 9fe0a5c67..553340fc8 100644 --- a/tests/suite/screens/plugin/constants.py +++ b/tests/suite/screens/plugin/constants.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import Final import pytest @@ -5,3 +6,4 @@ from tests.suite.screens.dearpygui.display import VirtualDisplay DISPLAY_KEY: Final[pytest.StashKey[VirtualDisplay]] = pytest.StashKey() +HOMES_KEY: Final[pytest.StashKey[Path]] = pytest.StashKey() diff --git a/tests/suite/screens/plugin/hooks.py b/tests/suite/screens/plugin/hooks.py index 5718f1bf7..5bdf7c402 100644 --- a/tests/suite/screens/plugin/hooks.py +++ b/tests/suite/screens/plugin/hooks.py @@ -1,4 +1,6 @@ import os +import shutil +import tempfile from pathlib import Path from typing import Final, Generator, Optional @@ -8,8 +10,8 @@ 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.paths import HOMES_PREFIX, SCREENS_DIRECTORY +from tests.suite.screens.plugin.constants import DISPLAY_KEY, HOMES_KEY from tests.suite.screens.plugin.fixtures import _worker_display CHILD_TIMEOUT_SECONDS: Final[float] = 600.0 @@ -42,7 +44,7 @@ def pytest_runtest_protocol( if is_child_process() or not is_screen_item(item): return None - folders = ScenarioFolders.of(item.nodeid) + folders = ScenarioFolders.of(item.nodeid, _worker_homes(item.config)) folders.prepare() environment = child_environment( os.environ, @@ -50,6 +52,7 @@ def pytest_runtest_protocol( display=_worker_display(item.config), ) item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location) + failed = False for report in run_isolated( item, environment=environment, @@ -57,12 +60,28 @@ def pytest_runtest_protocol( report_path=folders.reports, timeout=CHILD_TIMEOUT_SECONDS, ): + failed = failed or report.failed item.ihook.pytest_runtest_logreport(report=report) + folders.finish(failed=failed) item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location) return True +def _worker_homes(config: pytest.Config) -> Path: + """The temporary folder this worker's scenarios keep their homes in, made with the first of them. + + Each worker makes its own, so runs from several checkouts at once keep their homes apart. + """ + homes = config.stash.get(HOMES_KEY, None) + if homes is not None: + return homes + + homes = Path(tempfile.mkdtemp(prefix=HOMES_PREFIX)) + config.stash[HOMES_KEY] = homes + return homes + + 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(): @@ -71,11 +90,15 @@ def pytest_configure(config: pytest.Config) -> None: def pytest_unconfigure(config: pytest.Config) -> None: - """Stops the virtual display this worker started, if it started one.""" + """Stops the virtual display this worker started, and lets its temporary homes go, if it made them.""" display = config.stash.get(DISPLAY_KEY, None) if display is not None: display.stop() + homes = config.stash.get(HOMES_KEY, None) + if homes is not None: + shutil.rmtree(homes, ignore_errors=True) + @pytest.hookimpl(wrapper=True) def pytest_pyfunc_call(pyfuncitem: pytest.Function) -> Generator[None, Optional[object], Optional[object]]: From 09c79d694811890679d627012ddd497b6dcdd8f2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 13:29:51 +0200 Subject: [PATCH 47/84] Changed: a gesture asked for again while its question waits answering for the latest request --- docs/development/application/dialogs.md | 18 +- src/sampletones_application/application.py | 76 ++--- .../utils/callbacks/gates.py | 124 ++++++--- .../edits/test_gestures_at_once.py | 67 ++++- .../test_application_gestures.py | 68 ++++- .../utils/callbacks/test_gates.py | 261 ++++++++++++++---- 6 files changed, 480 insertions(+), 134 deletions(-) diff --git a/docs/development/application/dialogs.md b/docs/development/application/dialogs.md index c79b4c928..14180a064 100644 --- a/docs/development/application/dialogs.md +++ b/docs/development/application/dialogs.md @@ -89,12 +89,22 @@ conversation at a time. The conversation is the chain of questions the gesture p a question reaches whoever asked it. An answer that goes on lets the request through. Cancel, Escape, the title bar's close and a save that failed turn it away. A request therefore always ends in one of the two. -While a conversation stands, the same gesture asked for again is absorbed, so two closes before the first is +While a conversation stands, the same gesture asked for again asks nothing, so two closes before the first is answered ask once. Once the conversation has ended, the gesture asks again. The span covers the wait for the edits of the open reconstruction too, so a gesture repeated while an edit is on its way asks once it lands. -`SingleFlight` (`utils/callbacks/gates.py`) holds the conversation, built from the request so a question can -speak of what the gesture asks for, and the composition root wraps every such gesture in one, whichever door it -is asked for through. +`SingleFlight` (`utils/callbacks/gates.py`) holds the conversation, and the composition root wraps every such +gesture in one, whichever door it is asked for through. + +**The latest request wins.** A gesture whose question is the same whatever it carries goes on with the +request made last. Starting or opening a document, editing a voice, closing and leaving ask this way. A +reader who double-clicks one file and then another before the question shows opens the second. The menu's +Open, asked for after a browser's file, shows the file dialog. Cancel drops every request the conversation +gathered. `LatestRequestFlight` holds such a conversation. + +**A question built from the request keeps its request.** Loading what a run wrote asks whether the open +document is backed by that very file, so the answer holds for that file alone. A press made while the question +stands is absorbed, and the answer loads the file the question named. `FirstRequestFlight` holds such a +conversation. ## Where it is written diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 3fe6ed5d8..8ae497f82 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -137,11 +137,11 @@ from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.utils.callbacks.gates import ( + FirstRequestFlight, Gate, GestureParameters, GestureResult, - SingleFlight, - fixed, + LatestRequestFlight, gated, waiting, ) @@ -483,7 +483,7 @@ def __init__( on_session_state_changed=self._on_reconstruction_state_changed, on_reconstruction_updated=self._on_reconstruction_updated, ) - self._reconstruction_opening: SingleFlight[[Optional[Path]]] = self._reconstruction_opening_flight() + self._reconstruction_opening: LatestRequestFlight[[Optional[Path]]] = self._reconstruction_opening_flight() self._original_audio_locator = OriginalAudioLocator( dialogs=self.dialogs, @@ -596,10 +596,7 @@ def __init__( dialogs=self.dialogs, playback_failures=self._playback_failures, status_bar=self.status_bar, - on_edit_voice_requested=self._document_flight( - self._reconstruction_coordinator.guard_edit_voice, - self._reconstruction_coordinator.open_project_voice, - ), + on_edit_voice_requested=self._voice_editing_flight(), on_favorite_changed=self._repaint_reconstruction_favorites, on_sample_reconstruction_replaced=self._reconstruction_coordinator.replace_sample, on_tab_switch=self._set_current_tab, @@ -679,7 +676,7 @@ def __init__( instructions_tab=self._instructions_tab, ) self.browser_manager.on_recordings_read = self._show_reconstruction_recordings - self._exiting: SingleFlight[[]] = self._exit_flight() + self._exiting: LatestRequestFlight[[]] = self._exit_flight() self._setup_gui() self._restore_current_items( @@ -751,7 +748,8 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: A gesture that reads or puts away a whole document, the project or the open reconstruction, waits for the edits of the open reconstruction made before it, so it acts on what the reader has drawn. A gesture that replaces or closes a document, or leaves, holds one - conversation at a time, so asking for it again before its question is answered asks once. + conversation at a time, so asking for it again before its question is answered asks once, + and the answer goes on with the request made last. """ after_edits = self._reconstruction_coordinator.after_edits return ShortcutBindings( @@ -820,20 +818,26 @@ def _document_flight( self, guard: Gate, arrive: Callable[GestureParameters, GestureResult], - ) -> SingleFlight[GestureParameters]: - """A gesture on a whole document as one conversation: the edits on their way land, then ``guard`` asks.""" - return SingleFlight( - fixed( - ( - waiting(self._reconstruction_coordinator.after_edits), - guard, - ) + ) -> LatestRequestFlight[GestureParameters]: + """A gesture on a whole document as one conversation: the edits on their way land, then ``guard`` asks. + + ``guard`` asks the same question whatever the gesture carries, so its answer lets through the + request made last. + """ + return LatestRequestFlight( + ( + waiting(self._reconstruction_coordinator.after_edits), + guard, ), arrive, ) - def _converted_loading_flight(self) -> SingleFlight[[Path]]: - """Loading what a run wrote as one conversation, whose question speaks of the file it loads.""" + def _converted_loading_flight(self) -> FirstRequestFlight[[Path]]: + """Loading what a run wrote as one conversation, whose question speaks of the file it loads. + + The question reads whether the open document is backed by that very file, so its answer holds + for that file alone, and the request that raised it is the one the answer loads. + """ coordinator = self._reconstruction_coordinator def conversation(filepath: Path) -> Sequence[Gate]: @@ -842,15 +846,25 @@ def conversation(filepath: Path) -> Sequence[Gate]: partial(coordinator.guard_load_converted, filepath), ) - return SingleFlight(conversation, coordinator.load) + return FirstRequestFlight(conversation, coordinator.load) - def _reconstruction_opening_flight(self) -> SingleFlight[[Optional[Path]]]: - """Opening a reconstruction as one conversation, whichever door asks: the menu or a browser.""" + def _reconstruction_opening_flight(self) -> LatestRequestFlight[[Optional[Path]]]: + """Opening a reconstruction as one conversation, whichever door asks: the menu or a browser. + + The answer opens what was asked for last, a browser's file or the menu's file dialog. + """ return self._document_flight( self._reconstruction_coordinator.guard_load, self._reconstruction_coordinator.open, ) + def _voice_editing_flight(self) -> LatestRequestFlight[[str]]: + """Editing a voice of the project as one conversation, whose answer opens the voice asked for last.""" + return self._document_flight( + self._reconstruction_coordinator.guard_edit_voice, + self._reconstruction_coordinator.open_project_voice, + ) + def _setup_shell(self, bindings: ShortcutBindings) -> None: self._shell.setup( bindings, @@ -1671,22 +1685,20 @@ def _mute_channel(self, generator: ChannelName) -> None: """Flips one channel of the sequencer's mix, the gesture the Channels submenu offers.""" self._sequencer_tab.toggle_channel(generator) - def _exit_flight(self) -> SingleFlight[[]]: + def _exit_flight(self) -> LatestRequestFlight[[]]: """The exit as one conversation, in which each owner of something unfinished asks in turn. The edits of the open reconstruction land first, so each question asks about what the reader has drawn. A close asked for again while the questions stand is absorbed, and Cancel on any of them ends the conversation. """ - return SingleFlight( - fixed( - ( - waiting(self._reconstruction_coordinator.after_edits), - self._project_coordinator.guard_exit, - self._reconstruction_coordinator.guard_exit, - self._main_tab.guard_exit, - self._instructions_tab.guard_exit, - ) + return LatestRequestFlight( + ( + waiting(self._reconstruction_coordinator.after_edits), + self._project_coordinator.guard_exit, + self._reconstruction_coordinator.guard_exit, + self._main_tab.guard_exit, + self._instructions_tab.guard_exit, ), self._exit_application, ) diff --git a/src/sampletones_application/utils/callbacks/gates.py b/src/sampletones_application/utils/callbacks/gates.py index a8e6c9ddb..cc9b86a1b 100644 --- a/src/sampletones_application/utils/callbacks/gates.py +++ b/src/sampletones_application/utils/callbacks/gates.py @@ -1,5 +1,6 @@ +from abc import ABC, abstractmethod from functools import partial -from typing import Callable, Generic, ParamSpec, Sequence, TypeVar +from typing import Callable, Generic, Optional, ParamSpec, Sequence, TypeVar from sampletones_shared.types.callback import VoidCallback @@ -27,20 +28,6 @@ def gate(proceed: VoidCallback, _decline: VoidCallback) -> None: return gate -def fixed(gates: Sequence[Gate]) -> Callable[..., Sequence[Gate]]: - """A conversation that asks ``gates`` whatever a request carries. - - Args: - gates: What stands between every request and its arrival, in the order the gates are asked. - """ - held = tuple(gates) - - def conversation(*_args: object, **_kwargs: object) -> Sequence[Gate]: - return held - - return conversation - - def pass_gates( gates: Sequence[Gate], arrive: VoidCallback, @@ -89,46 +76,107 @@ def run() -> None: return call -class SingleFlight(Generic[GestureParameters]): - """A gesture that holds one conversation at a time, absorbing a repeat asked for while one is in flight. +class SingleFlight(ABC, Generic[GestureParameters]): + """A gesture that holds one conversation at a time, and lets one request through at its end. - The conversation is the gates the gesture passes, built from the request's arguments, so a - question can speak of what the request asks for. It is in flight from the moment the gesture is - asked for until the gates let it through or turn it away. A gesture asked for twice before its - question is answered therefore asks once, and one asked for after the answer asks again. A gate - that raises ends the flight too, so one failure leaves the gesture to be asked for again. + The conversation is the gates the gesture passes. It is in flight from the moment the gesture is + asked for until the gates let it through or turn it away, and a gesture asked for after that asks + again. A gesture asked for while it is in flight asks nothing, and the kind of flight decides which + request its end lets through. A conversation that turns the gesture away drops the request it + holds. A gate that raises ends the flight the same way, so one failure leaves the gesture to be + asked for again. """ - def __init__( - self, - conversation: Callable[GestureParameters, Sequence[Gate]], - arrive: Callable[GestureParameters, GestureResult], - ) -> None: - self._conversation = conversation + def __init__(self, arrive: Callable[GestureParameters, GestureResult]) -> None: self._arrive = arrive - self._in_flight: bool = False + self._standing: Optional[VoidCallback] = None @property def in_flight(self) -> bool: """Whether a conversation of this gesture is under way, from the moment it is asked for to its end.""" - return self._in_flight + return self._standing is not None + @abstractmethod def __call__(self, *args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> None: - if self._in_flight: - return + """Asks for the gesture with these arguments.""" + + def _request(self, *args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> VoidCallback: + """The gesture's arrival with these arguments, which discards what the gesture returns.""" - def arrive() -> None: - self._land() + def arrival() -> None: self._arrive(*args, **kwargs) - self._in_flight = True + return arrival + + def _take_off(self, gates: Sequence[Gate], request: VoidCallback) -> None: + """Asks ``gates`` with ``request`` standing, and holds the flight until they let it through or turn it away.""" + self._standing = request asked = False try: - pass_gates(self._conversation(*args, **kwargs), arrive, self._land) + pass_gates(gates, self._land, self._turn_away) asked = True finally: if not asked: - self._land() + self._turn_away() + + def _redirect(self, request: VoidCallback) -> None: + """Lets ``request`` through at the end of the flight under way, in the place of the one standing.""" + self._standing = request def _land(self) -> None: - self._in_flight = False + """Ends the flight, and then lets the request standing through, so its arrival may ask anew.""" + request = self._standing + assert request is not None, "A conversation let its gesture through after its flight had ended" + self._standing = None + request() + + def _turn_away(self) -> None: + self._standing = None + + +class LatestRequestFlight(SingleFlight[GestureParameters]): + """A gesture whose conversation is the same whatever it carries, letting the latest request through. + + The gates are asked once for the whole flight, and a request made meanwhile takes the place of the + one standing. The answer therefore goes on with what the reader asked for last, such as the second + of two files opened before the question showed. + """ + + def __init__( + self, + gates: Sequence[Gate], + arrive: Callable[GestureParameters, GestureResult], + ) -> None: + super().__init__(arrive) + self._gates = tuple(gates) + + def __call__(self, *args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> None: + request = self._request(*args, **kwargs) + if self.in_flight: + self._redirect(request) + return + + self._take_off(self._gates, request) + + +class FirstRequestFlight(SingleFlight[GestureParameters]): + """A gesture whose conversation speaks of what it carries, keeping the first request to the end. + + The conversation is built from the request's arguments, so a question can name the file it asks + about, and its answer holds for that request alone. A request made while the question stands is + therefore absorbed, and one made after the answer asks about what it carries. + """ + + def __init__( + self, + conversation: Callable[GestureParameters, Sequence[Gate]], + arrive: Callable[GestureParameters, GestureResult], + ) -> None: + super().__init__(arrive) + self._conversation = conversation + + def __call__(self, *args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> None: + if self.in_flight: + return + + self._take_off(self._conversation(*args, **kwargs), self._request(*args, **kwargs)) diff --git a/tests/screens/reconstructions/edits/test_gestures_at_once.py b/tests/screens/reconstructions/edits/test_gestures_at_once.py index 5753167e6..e109b5f3f 100644 --- a/tests/screens/reconstructions/edits/test_gestures_at_once.py +++ b/tests/screens/reconstructions/edits/test_gestures_at_once.py @@ -14,8 +14,10 @@ from tests.suite.screens.steps.project import save_project_as from tests.suite.screens.steps.reconstructions import ( expect_open, + load_from_the_browser, marked, raise_the_first_level, + raise_the_first_level_while_held, stored_levels, titled, voice_title, @@ -23,7 +25,16 @@ 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.worlds.recordings import SHORT_RECONSTRUCTION, SONG, SONG_SAMPLE, STEMS_RECONSTRUCTION +from tests.suite.screens.vocabulary.dialogs import LOAD_TITLE +from tests.suite.screens.worlds.recordings import ( + OPEN_RECONSTRUCTION, + PLAYABLE_RECONSTRUCTION, + SECOND_PLAYABLE, + SHORT_RECONSTRUCTION, + SONG, + SONG_SAMPLE, + STEMS_RECONSTRUCTION, +) DRAGGED_ITEM: Final[int] = 5 FIRST_ITEM: Final[int] = 1 @@ -301,3 +312,57 @@ def both_stay(screen: Screen) -> None: both_stay, leave_letting_it_go, ).run() + + +class TestTwoOpensWhileAnEditIsOnItsWay: + """Two reconstructions double-clicked while an edit is on its way ask once, and the answer opens the second. + + The first level is raised while the rebuild is held, and two other reconstructions are double-clicked in + the browser. Once released, one question about the unsaved changes shows. Discard opens the second + reconstruction, and nothing asks after it. + """ + + @pytest.fixture + def startup(self) -> Startup: + """A reconstruction is open at start, with no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_second_opens(self, screen: Screen, regeneration_hold: RegenerationHold) -> None: + """One question shows, and Discard opens the reconstruction double-clicked last.""" + prompt = screen.reconstructions.unsaved_prompt + + def edit_while_the_rebuild_is_held(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + + raise_the_first_level_while_held(screen, regeneration_hold, ChannelName.PULSE1) + + def open_two_while_it_is_held(screen: Screen) -> None: + load_from_the_browser(screen, PLAYABLE_RECONSTRUCTION) + load_from_the_browser(screen, SECOND_PLAYABLE) + + screen.frames(SETTLE_FRAMES) + assert screen.shown_windows() == () + assert screen.reconstructions.shows_open(OPEN_RECONSTRUCTION) + + def the_edit_lands_and_one_question_comes(screen: Screen) -> None: + regeneration_hold.release() + + screen.expect(prompt.is_shown, bool, description="the question about unsaved changes") + assert prompt.title() == screen.words(LOAD_TITLE) + screen.frames(SETTLE_FRAMES) + assert len(screen.shown_windows()) == 1 + + def discard_opens_the_second(screen: Screen) -> None: + prompt.confirm() + + expect_open(screen, SECOND_PLAYABLE) + screen.frames(SETTLE_FRAMES) + assert screen.shown_windows() == () + assert screen.title() == titled(screen, SECOND_PLAYABLE.name) + + screen.scenario( + edit_while_the_rebuild_is_held, + open_two_while_it_is_held, + the_edit_lands_and_one_question_comes, + discard_opens_the_second, + ).run() diff --git a/tests/unit/sampletones_application/test_application_gestures.py b/tests/unit/sampletones_application/test_application_gestures.py index 24ed19052..a07a5dc06 100644 --- a/tests/unit/sampletones_application/test_application_gestures.py +++ b/tests/unit/sampletones_application/test_application_gestures.py @@ -33,6 +33,8 @@ ) PROJECT_GUARDS: Final[Tuple[str, ...]] = ("guard_new", "guard_open", "guard_close") RECONSTRUCTION_GUARDS: Final[Tuple[str, ...]] = ("guard_load", "guard_close") +FIRST_VOICE: Final[str] = "first-voice" +SECOND_VOICE: Final[str] = "second-voice" def let_through(proceed: VoidCallback, _decline: VoidCallback) -> None: @@ -192,7 +194,7 @@ def test_undo_leaves_the_wait_to_the_sequencer( class TestADocumentGestureAsksOnce(BaseTestSuite): """A gesture that replaces or closes a document, asked for twice while the edits before it are on - their way, asks its question once. + their way, asks its question once, and the answer goes on with the request made last. """ @dataclass(frozen=True, kw_only=True) @@ -277,7 +279,10 @@ def test_a_browser_and_the_menu_share_one_opening( bindings: ShortcutBindings, held_gate: HeldGate, ) -> None: - """Opening a reconstruction asks once whichever door it was asked for through.""" + """Opening a reconstruction asks once whichever door it was asked for through. + + The menu's Open, asked for last, is what the answer goes on with, so the reader picks a file. + """ guard = app._reconstruction_coordinator.guard_load guard.side_effect = None @@ -288,11 +293,48 @@ def test_a_browser_and_the_menu_share_one_opening( guard.assert_called_once() proceed, _ = guard.call_args.args proceed() - app._reconstruction_coordinator.open.assert_called_once_with(Path("browsed.stn")) + app._reconstruction_coordinator.open.assert_called_once_with() + + def test_two_browsed_files_open_the_second( + self, + app: Application, + held_gate: HeldGate, + ) -> None: + guard = app._reconstruction_coordinator.guard_load + guard.side_effect = None + + app._reconstruction_opening(Path("drums.stn")) + app._reconstruction_opening(Path("bass.stn")) + held_gate.release() + + guard.assert_called_once() + proceed, _ = guard.call_args.args + proceed() + app._reconstruction_coordinator.open.assert_called_once_with(Path("bass.stn")) + + def test_editing_two_voices_opens_the_second( + self, + app: Application, + held_gate: HeldGate, + ) -> None: + guard = app._reconstruction_coordinator.guard_edit_voice + editing = app._voice_editing_flight() + + editing(FIRST_VOICE) + editing(SECOND_VOICE) + held_gate.release() + + guard.assert_called_once() + proceed, _ = guard.call_args.args + proceed() + app._reconstruction_coordinator.open_project_voice.assert_called_once_with(SECOND_VOICE) class TestLoadingWhatARunWroteAsksOnce: - """The Converter's Load asks about the file it loads once, however often it is pressed meanwhile.""" + """The Converter's Load asks about the file it loads once, however often it is pressed meanwhile. + + The question speaks of the file the first press asked for, so the answer loads that file. + """ @pytest.fixture def guard(self, app: Application) -> MagicMock: @@ -316,6 +358,24 @@ def test_two_presses_while_an_edit_is_on_its_way_ask_once( assert guard.call_args.args[0] == Path("written.stn") app._reconstruction_coordinator.load.assert_not_called() + def test_a_second_file_asked_for_meanwhile_keeps_the_first( + self, + app: Application, + guard: MagicMock, + held_gate: HeldGate, + ) -> None: + loading = app._converted_loading_flight() + + loading(Path("written.stn")) + loading(Path("rewritten.stn")) + held_gate.release() + + guard.assert_called_once() + filepath, proceed, _ = guard.call_args.args + assert filepath == Path("written.stn") + proceed() + app._reconstruction_coordinator.load.assert_called_once_with(Path("written.stn")) + def test_the_answer_loads_the_file_the_question_spoke_of( self, app: Application, diff --git a/tests/unit/sampletones_application/utils/callbacks/test_gates.py b/tests/unit/sampletones_application/utils/callbacks/test_gates.py index 5bf3f4de9..dd24eaa90 100644 --- a/tests/unit/sampletones_application/utils/callbacks/test_gates.py +++ b/tests/unit/sampletones_application/utils/callbacks/test_gates.py @@ -1,10 +1,21 @@ +from dataclasses import dataclass from pathlib import Path -from typing import Final, List, Optional +from typing import Callable, Final, List, Optional, Sequence import pytest -from sampletones_application.utils.callbacks.gates import Gate, SingleFlight, fixed, gated, pass_gates, waiting +from sampletones_application.utils.callbacks.gates import ( + FirstRequestFlight, + Gate, + LatestRequestFlight, + SingleFlight, + gated, + pass_gates, + waiting, +) from sampletones_shared.types.callback import VoidCallback +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase ARRIVED: Final[str] = "arrived" DECLINED: Final[str] = "declined" @@ -153,38 +164,63 @@ def test_a_gated_gesture_runs_with_its_arguments_once_the_wait_ends(self) -> Non assert opened == [Path("song.stp")] -class TestSingleFlight: - """A gesture holds one conversation at a time: a repeat while it is in flight is absorbed. +class RaisingOnce: + """A gate that fails the first time it is reached and lets every later request through.""" + + def __init__(self, reached: List[str]) -> None: + self._reached = reached + self._raised = False + + def __call__(self, proceed: VoidCallback, _decline: VoidCallback) -> None: + self._reached.append("broken") + if not self._raised: + self._raised = True + raise RuntimeError("the question could not be asked") + + proceed() + + +def latest_request(gates: Sequence[Gate], arrive: VoidCallback) -> SingleFlight[[]]: + return LatestRequestFlight(gates, arrive) + + +def first_request(gates: Sequence[Gate], arrive: VoidCallback) -> SingleFlight[[]]: + return FirstRequestFlight(lambda: gates, arrive) + + +class TestEitherFlight(BaseTestSuite): + """A gesture holds one conversation at a time, whichever request its end lets through. The flight ends when the gates let the gesture through or turn it away, so a gesture asked for after either asks again. """ + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + build: Callable[[Sequence[Gate], VoidCallback], SingleFlight[[]]] + + test_cases = ( + TestCase(label="latest_request", build=latest_request), + TestCase(label="first_request", build=first_request), + ) + @pytest.fixture(name="guard") def guard_fixture(self, reached: List[str]) -> Guard: return Guard("question", reached, unfinished=True) - @pytest.fixture(name="flight") - def flight_fixture(self, guard: Guard, reached: List[str]) -> SingleFlight[[]]: - return SingleFlight(fixed((guard,)), lambda: reached.append(ARRIVED)) + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_two_requests_ask_once(self, test_case: TestCase, guard: Guard, reached: List[str]) -> None: + flight = test_case.build((guard,), lambda: reached.append(ARRIVED)) - def test_a_repeat_while_the_question_stands_is_absorbed( - self, - flight: SingleFlight[[]], - reached: List[str], - ) -> None: flight() flight() assert reached == ["question"] assert flight.in_flight - def test_the_absorbed_repeat_arrives_once( - self, - flight: SingleFlight[[]], - guard: Guard, - reached: List[str], - ) -> None: + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_answer_arrives_once(self, test_case: TestCase, guard: Guard, reached: List[str]) -> None: + flight = test_case.build((guard,), lambda: reached.append(ARRIVED)) flight() flight() @@ -193,12 +229,14 @@ def test_the_absorbed_repeat_arrives_once( assert reached == ["question", ARRIVED] assert not flight.in_flight - def test_a_gesture_after_an_arrival_asks_again( + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_request_after_an_arrival_asks_again( self, - flight: SingleFlight[[]], + test_case: TestCase, guard: Guard, reached: List[str], ) -> None: + flight = test_case.build((guard,), lambda: reached.append(ARRIVED)) flight() guard.answer() @@ -207,12 +245,14 @@ def test_a_gesture_after_an_arrival_asks_again( assert reached == ["question", ARRIVED, "question"] assert flight.in_flight - def test_a_gesture_after_a_decline_asks_again( + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_request_after_a_decline_asks_again( self, - flight: SingleFlight[[]], + test_case: TestCase, guard: Guard, reached: List[str], ) -> None: + flight = test_case.build((guard,), lambda: reached.append(ARRIVED)) flight() guard.cancel() assert not flight.in_flight @@ -222,10 +262,15 @@ def test_a_gesture_after_a_decline_asks_again( assert reached == ["question", "question"] assert guard.is_asking - def test_a_repeat_while_a_wait_holds_the_gesture_is_absorbed(self, reached: List[str]) -> None: + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_two_requests_while_a_wait_holds_the_gesture_ask_once( + self, + test_case: TestCase, + guard: Guard, + reached: List[str], + ) -> None: wait = HeldWait() - guard = Guard("question", reached, unfinished=True) - flight: SingleFlight[[]] = SingleFlight(fixed((waiting(wait), guard)), lambda: reached.append(ARRIVED)) + flight = test_case.build((waiting(wait), guard), lambda: reached.append(ARRIVED)) flight() flight() @@ -233,15 +278,147 @@ def test_a_repeat_while_a_wait_holds_the_gesture_is_absorbed(self, reached: List assert reached == ["question"] - def test_the_arrival_takes_the_arguments_of_the_gesture_that_asked(self, reached: List[str]) -> None: + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_gesture_with_nothing_to_ask_arrives_and_lands(self, test_case: TestCase, reached: List[str]) -> None: + flight = test_case.build((Guard("clear", reached, unfinished=False),), lambda: reached.append(ARRIVED)) + + flight() + flight() + + assert reached == ["clear", ARRIVED, "clear", ARRIVED] + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_gate_that_raises_ends_the_flight(self, test_case: TestCase, reached: List[str]) -> None: + def broken(_proceed: VoidCallback, _decline: VoidCallback) -> None: + reached.append("broken") + raise RuntimeError("the question could not be asked") + + flight = test_case.build((broken,), lambda: reached.append(ARRIVED)) + + with pytest.raises(RuntimeError): + flight() + + assert not flight.in_flight + with pytest.raises(RuntimeError): + flight() + assert reached == ["broken", "broken"] + + +class TestLatestRequestFlight: + """A conversation that is the same whatever the gesture carries lets the latest request through. + + The question is asked once for the whole flight, a decline drops every request made during it, and + a request made after the flight ended asks again with what it carries. + """ + + @pytest.fixture(name="guard") + def guard_fixture(self, reached: List[str]) -> Guard: + return Guard("question", reached, unfinished=True) + + @pytest.fixture(name="opened") + def opened_fixture(self) -> List[Path]: + return [] + + @pytest.fixture(name="flight") + def flight_fixture(self, guard: Guard, opened: List[Path]) -> LatestRequestFlight[[Path]]: + return LatestRequestFlight((guard,), opened.append) + + def test_the_answer_goes_on_with_the_latest_request( + self, + flight: LatestRequestFlight[[Path]], + guard: Guard, + reached: List[str], + opened: List[Path], + ) -> None: + flight(Path("first.stn")) + flight(Path("second.stn")) + flight(Path("third.stn")) + + guard.answer() + + assert reached == ["question"] + assert opened == [Path("third.stn")] + + def test_a_request_made_while_a_wait_holds_the_gesture_arrives( + self, + guard: Guard, + reached: List[str], + opened: List[Path], + ) -> None: + """Two files opened while the edits are on their way ask once, and the answer opens the second.""" + wait = HeldWait() + flight: LatestRequestFlight[[Path]] = LatestRequestFlight((waiting(wait), guard), opened.append) + flight(Path("drums.stn")) + flight(Path("bass.stn")) + wait.release() + + guard.answer() + + assert reached == ["question"] + assert opened == [Path("bass.stn")] + + def test_a_decline_drops_every_request_of_the_flight( + self, + flight: LatestRequestFlight[[Path]], + guard: Guard, + reached: List[str], + opened: List[Path], + ) -> None: + flight(Path("first.stn")) + flight(Path("second.stn")) + guard.cancel() + + flight(Path("third.stn")) + guard.answer() + + assert reached == ["question", "question"] + assert opened == [Path("third.stn")] + + def test_a_request_after_the_arrival_starts_a_new_conversation( + self, + flight: LatestRequestFlight[[Path]], + guard: Guard, + reached: List[str], + opened: List[Path], + ) -> None: + flight(Path("first.stn")) + guard.answer() + + flight(Path("second.stn")) + assert opened == [Path("first.stn")] + guard.answer() + + assert reached == ["question", "question"] + assert opened == [Path("first.stn"), Path("second.stn")] + + def test_a_gate_that_raises_drops_its_request(self, reached: List[str], opened: List[Path]) -> None: + flight: LatestRequestFlight[[Path]] = LatestRequestFlight((RaisingOnce(reached),), opened.append) + with pytest.raises(RuntimeError): + flight(Path("first.stn")) + + flight(Path("second.stn")) + + assert reached == ["broken", "broken"] + assert opened == [Path("second.stn")] + + +class TestFirstRequestFlight: + """A conversation built from the request keeps the first request, so the answer holds for what it asked about. + + A request made while the question stands is absorbed, and one made after the answer asks about + what it carries. + """ + + def test_the_answer_goes_on_with_the_first_request(self, reached: List[str]) -> None: guard = Guard("question", reached, unfinished=True) opened: List[Path] = [] - flight: SingleFlight[[Path]] = SingleFlight(fixed((guard,)), opened.append) + flight: FirstRequestFlight[[Path]] = FirstRequestFlight(lambda _path: (guard,), opened.append) flight(Path("first.stn")) flight(Path("second.stn")) guard.answer() + assert reached == ["question"] assert opened == [Path("first.stn")] def test_the_question_is_built_from_the_gesture_that_asked(self, reached: List[str]) -> None: @@ -254,7 +431,7 @@ def conversation(path: Path) -> List[Gate]: return [guard] opened: List[Path] = [] - flight: SingleFlight[[Path]] = SingleFlight(conversation, opened.append) + flight: FirstRequestFlight[[Path]] = FirstRequestFlight(conversation, opened.append) flight(Path("first.stn")) flight(Path("second.stn")) @@ -269,36 +446,10 @@ def test_a_conversation_that_raises_ends_the_flight(self, reached: List[str]) -> def conversation() -> List[Gate]: raise RuntimeError("the question could not be built") - flight: SingleFlight[[]] = SingleFlight(conversation, lambda: reached.append(ARRIVED)) + flight: FirstRequestFlight[[]] = FirstRequestFlight(conversation, lambda: reached.append(ARRIVED)) with pytest.raises(RuntimeError): flight() assert not flight.in_flight assert not reached - - def test_a_gesture_with_nothing_to_ask_arrives_and_lands(self, reached: List[str]) -> None: - flight: SingleFlight[[]] = SingleFlight( - fixed((Guard("clear", reached, unfinished=False),)), - lambda: reached.append(ARRIVED), - ) - - flight() - flight() - - assert reached == ["clear", ARRIVED, "clear", ARRIVED] - - def test_a_gate_that_raises_ends_the_flight(self, reached: List[str]) -> None: - def broken(_proceed: VoidCallback, _decline: VoidCallback) -> None: - reached.append("broken") - raise RuntimeError("the question could not be asked") - - flight: SingleFlight[[]] = SingleFlight(fixed((broken,)), lambda: reached.append(ARRIVED)) - - with pytest.raises(RuntimeError): - flight() - - assert not flight.in_flight - with pytest.raises(RuntimeError): - flight() - assert reached == ["broken", "broken"] From 19c40c49560ab9d9082439faa88caeac8c65c82e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 15:41:12 +0200 Subject: [PATCH 48/84] Removed: the gates' ignore, with the reconstruction cases recording their declines --- .../utils/callbacks/gates.py | 4 - .../coordinators/test_reconstruction.py | 90 ++++++++++++++----- 2 files changed, 67 insertions(+), 27 deletions(-) diff --git a/src/sampletones_application/utils/callbacks/gates.py b/src/sampletones_application/utils/callbacks/gates.py index cc9b86a1b..ccf5f5257 100644 --- a/src/sampletones_application/utils/callbacks/gates.py +++ b/src/sampletones_application/utils/callbacks/gates.py @@ -11,10 +11,6 @@ GestureResult = TypeVar("GestureResult") -def ignore() -> None: - """Answers a request turned away whose asker carries on as it stands, so the request ends where it stopped.""" - - def waiting(wait: Wait) -> Gate: """``wait`` as a gate that holds a request until it may go on, and then lets it through. diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index fd0c9b0d7..103188e3c 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -29,7 +29,7 @@ from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ServiceSuccess from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED -from sampletones_application.utils.callbacks.gates import ignore, pass_gates +from sampletones_application.utils.callbacks.gates import pass_gates from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import SILENT_VOLUME @@ -44,6 +44,7 @@ InvalidReconstructionValuesError, ) from sampletones_shared.paths.extensions import EXT_FILE_PROJECT +from sampletones_shared.types.callback import VoidCallback from tests.conftest import ReconstructionFactory from tests.suite.application import HeldQueue, held_queue, scheduling, synchronous_executor from tests.suite.base import BaseTestSuite @@ -119,19 +120,37 @@ def _undo(coordinator: ReconstructionCoordinator, history: HistoryManager) -> No coordinator.after_edits(history.undo) -def _load_by_hand(coordinator: ReconstructionCoordinator, filepath: Path) -> None: +def _load_by_hand( + coordinator: ReconstructionCoordinator, + filepath: Path, + decline: VoidCallback, +) -> None: """Asks to open ``filepath`` the way the reconstruction menu does, past the question it raises.""" - pass_gates((coordinator.guard_load,), partial(coordinator.open, filepath), ignore) + pass_gates((coordinator.guard_load,), partial(coordinator.open, filepath), decline) -def _load_converted(coordinator: ReconstructionCoordinator, filepath: Path) -> None: +def _load_converted( + coordinator: ReconstructionCoordinator, + filepath: Path, + decline: VoidCallback, +) -> None: """Asks to load what a conversion wrote the way the Converter's Load does, past the question it raises.""" - pass_gates((partial(coordinator.guard_load_converted, filepath),), partial(coordinator.load, filepath), ignore) + pass_gates((partial(coordinator.guard_load_converted, filepath),), partial(coordinator.load, filepath), decline) -def _edit_voice(coordinator: ReconstructionCoordinator, voice_id: str) -> None: +def _edit_voice( + coordinator: ReconstructionCoordinator, + voice_id: str, + decline: VoidCallback, +) -> None: """Asks to edit a voice of the project the way the Sequencer does, past the question it raises.""" - pass_gates((coordinator.guard_edit_voice,), partial(coordinator.open_project_voice, voice_id), ignore) + pass_gates((coordinator.guard_edit_voice,), partial(coordinator.open_project_voice, voice_id), decline) + + +@pytest.fixture +def decline() -> MagicMock: + """What a request the gates turn away runs, recording that it ran.""" + return MagicMock() def _save(coordinator: ReconstructionCoordinator) -> None: @@ -488,6 +507,7 @@ def test_close_prompts_only_for_standalone_unsaved( def test_load_prompts_only_for_standalone_unsaved( self, test_case: TestCase, + decline: MagicMock, ) -> None: coordinator = _gating_coordinator( unsaved=test_case.unsaved, @@ -495,7 +515,7 @@ def test_load_prompts_only_for_standalone_unsaved( ) path = Path("lead.stn") - _load_by_hand(coordinator, path) + _load_by_hand(coordinator, path, decline) if test_case.expects_prompt: coordinator._dialogs.show_save_confirmation.assert_called_once() @@ -503,6 +523,7 @@ def test_load_prompts_only_for_standalone_unsaved( else: coordinator._dialogs.show_save_confirmation.assert_not_called() coordinator._reconstructions_tab.load_reconstruction.assert_called_once_with(path) + decline.assert_not_called() @pytest.fixture @@ -602,11 +623,12 @@ def open_sample( tab: MagicMock, held_queue: HeldQueue, reconstruction_factory: ReconstructionFactory, + decline: MagicMock, ) -> Sample: """A sample added to the project and opened on the tab, with the calls opening it cleared.""" with history.transaction(HistoryAction.ADD_SAMPLE): sample = project_controller.add_sample(reconstruction_factory(), "lead") - _edit_voice(following_coordinator, sample.id) + _edit_voice(following_coordinator, sample.id, decline) held_queue.drain() tab.reset_mock() return sample @@ -730,11 +752,12 @@ def test_a_redo_taking_the_sample_out_closes_it( project_controller: ProjectController, history: HistoryManager, open_sample: Sample, + decline: MagicMock, ) -> None: with history.transaction(HistoryAction.REMOVE_VOICE): project_controller.remove_voice(open_sample.id) history.undo() - _edit_voice(following_coordinator, open_sample.id) + _edit_voice(following_coordinator, open_sample.id, decline) history.redo() @@ -904,16 +927,18 @@ def test_a_sample_opens_as_the_document_it_is( history: HistoryManager, tab: MagicMock, reconstruction_factory: ReconstructionFactory, + decline: MagicMock, ) -> None: voice_id = self._add_voice(VoiceKind.SAMPLE, project_controller, history, reconstruction_factory) sample = project_controller.project.voice(voice_id) assert isinstance(sample, Sample) - _edit_voice(following_coordinator, voice_id) + _edit_voice(following_coordinator, voice_id, decline) assert reconstruction_manager.voice_id == voice_id assert reconstruction_manager.reconstruction is sample.reconstruction tab.release_instrument.assert_called_once_with() + decline.assert_not_called() def test_an_instrument_opens_in_the_editor_on_its_tab( self, @@ -922,21 +947,24 @@ def test_an_instrument_opens_in_the_editor_on_its_tab( history: HistoryManager, tab: MagicMock, reconstruction_factory: ReconstructionFactory, + decline: MagicMock, ) -> None: voice_id = self._add_voice(VoiceKind.INSTRUMENT, project_controller, history, reconstruction_factory) - _edit_voice(following_coordinator, voice_id) + _edit_voice(following_coordinator, voice_id, decline) tab.edit_instrument.assert_called_once_with(voice_id) following_coordinator._on_tab_switch.assert_called_once_with(Tab.RECONSTRUCTIONS) + decline.assert_not_called() def test_an_unknown_voice_opens_nothing( self, following_coordinator: ReconstructionCoordinator, reconstruction_manager: ReconstructionManager, tab: MagicMock, + decline: MagicMock, ) -> None: - _edit_voice(following_coordinator, "gone") + _edit_voice(following_coordinator, "gone", decline) assert reconstruction_manager.current_reconstruction is None tab.edit_instrument.assert_not_called() @@ -956,15 +984,17 @@ def test_an_unsaved_standalone_document_is_offered_a_save_first( tab: MagicMock, standalone_path: Path, reconstruction_factory: ReconstructionFactory, + decline: MagicMock, ) -> None: voice_id = self._add_voice(test_case.kind, project_controller, history, reconstruction_factory) reconstruction_manager.mark_updated() - _edit_voice(following_coordinator, voice_id) + _edit_voice(following_coordinator, voice_id, decline) following_coordinator._dialogs.show_save_confirmation.assert_called_once() assert reconstruction_manager.filepath == standalone_path tab.edit_instrument.assert_not_called() + decline.assert_not_called() @pytest.mark.parametrize( "test_case", @@ -981,10 +1011,11 @@ def test_the_answer_opens_the_voice( tab: MagicMock, standalone_path: Path, reconstruction_factory: ReconstructionFactory, + decline: MagicMock, ) -> None: voice_id = self._add_voice(test_case.kind, project_controller, history, reconstruction_factory) reconstruction_manager.mark_updated() - _edit_voice(following_coordinator, voice_id) + _edit_voice(following_coordinator, voice_id, decline) following_coordinator._dialogs.show_save_confirmation.call_args.kwargs["on_confirm"]() @@ -993,6 +1024,7 @@ def test_the_answer_opens_the_voice( assert reconstruction_manager.voice_id == voice_id case VoiceKind.INSTRUMENT: tab.edit_instrument.assert_called_once_with(voice_id) + decline.assert_not_called() def test_an_edited_project_sample_opens_another_voice_at_once( self, @@ -1002,15 +1034,17 @@ def test_an_edited_project_sample_opens_another_voice_at_once( history: HistoryManager, open_sample: Sample, reconstruction_factory: ReconstructionFactory, + decline: MagicMock, ) -> None: """A project sample's edits belong to the project, so putting it away loses nothing.""" voice_id = self._add_voice(VoiceKind.SAMPLE, project_controller, history, reconstruction_factory) reconstruction_manager.mark_updated() - _edit_voice(following_coordinator, voice_id) + _edit_voice(following_coordinator, voice_id, decline) following_coordinator._dialogs.show_save_confirmation.assert_not_called() assert reconstruction_manager.voice_id == voice_id + decline.assert_not_called() class TestAnOutsideRewriteOfTheOpenSample: @@ -1273,12 +1307,14 @@ def test_the_question_put_before_loading( test_case: TestCase, reconstruction_factory: ReconstructionFactory, tmp_path: Path, + decline: MagicMock, ) -> None: coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) - _load_converted(coordinator, self._converted(test_case, tmp_path)) + _load_converted(coordinator, self._converted(test_case, tmp_path), decline) assert self._asked(coordinator) == test_case.expected + decline.assert_not_called() @pytest.mark.parametrize( "test_case", @@ -1290,11 +1326,12 @@ def test_going_on_loads_what_the_conversion_wrote( test_case: TestCase, reconstruction_factory: ReconstructionFactory, tmp_path: Path, + decline: MagicMock, ) -> None: coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) converted = self._converted(test_case, tmp_path) - _load_converted(coordinator, converted) + _load_converted(coordinator, converted, decline) asked = self._asked(coordinator) if asked == SAVE_PROMPT: coordinator._dialogs.show_save_confirmation.call_args.kwargs["on_confirm"]() @@ -1302,6 +1339,7 @@ def test_going_on_loads_what_the_conversion_wrote( coordinator._dialogs.show_confirmation.call_args.kwargs["on_confirm"]() coordinator._tab.load_reconstruction.assert_called_once_with(converted) + decline.assert_not_called() @pytest.mark.parametrize( "test_case", @@ -1313,24 +1351,27 @@ def test_nothing_loads_before_the_answer( test_case: TestCase, reconstruction_factory: ReconstructionFactory, tmp_path: Path, + decline: MagicMock, ) -> None: coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) - _load_converted(coordinator, self._converted(test_case, tmp_path)) + _load_converted(coordinator, self._converted(test_case, tmp_path), decline) coordinator._tab.load_reconstruction.assert_not_called() assert coordinator.is_unsaved() + decline.assert_not_called() def test_a_replaced_file_offers_to_discard_the_changes( self, reconstruction_factory: ReconstructionFactory, tmp_path: Path, + decline: MagicMock, ) -> None: """Saving would write the old document over the conversion, so the prompt offers no Save.""" test_case = next(test_case for test_case in self.test_cases if test_case.expected == REPLACED_PROMPT) coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) - _load_converted(coordinator, self._converted(test_case, tmp_path)) + _load_converted(coordinator, self._converted(test_case, tmp_path), decline) prompt = coordinator._dialogs.show_confirmation.call_args.kwargs assert prompt["message"] == REPLACED_MESSAGE_KEY @@ -1341,10 +1382,11 @@ def test_the_save_prompt_saves_the_open_document( self, reconstruction_factory: ReconstructionFactory, tmp_path: Path, + decline: MagicMock, ) -> None: test_case = next(test_case for test_case in self.test_cases if test_case.expected == SAVE_PROMPT) coordinator = self._coordinator(test_case, reconstruction_factory, tmp_path) - _load_converted(coordinator, self._converted(test_case, tmp_path)) + _load_converted(coordinator, self._converted(test_case, tmp_path), decline) outcome = coordinator._dialogs.show_save_confirmation.call_args.kwargs["on_save"]() @@ -1416,11 +1458,12 @@ def turns_sample( tab: MagicMock, held_queue: HeldQueue, taking_turns: Reconstruction, + decline: MagicMock, ) -> Sample: """The two-recording document added to the project and opened on the tab.""" with history.transaction(HistoryAction.ADD_SAMPLE): sample = project_controller.add_sample(taking_turns, "turns") - _edit_voice(following_coordinator, sample.id) + _edit_voice(following_coordinator, sample.id, decline) held_queue.drain() tab.reset_mock() return sample @@ -1508,13 +1551,14 @@ def test_opening_another_voice_mid_flight_leaves_it_as_it_was( held_queue: HeldQueue, turns_sample: Sample, reconstruction_factory: ReconstructionFactory, + decline: MagicMock, ) -> None: with history.transaction(HistoryAction.ADD_SAMPLE): other = project_controller.add_sample(reconstruction_factory(), "other") original = other.reconstruction entries = len(history.entries) _move(following_coordinator, SHARED_CHANNEL, FeatureKey.VOLUME, EDITED_VOLUME) - _edit_voice(following_coordinator, other.id) + _edit_voice(following_coordinator, other.id, decline) held_queue.drain() sample = project_controller.project.voice(other.id) From 93498294759299b1b8016e7dc57ab6af96945172 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 15:46:50 +0200 Subject: [PATCH 49/84] Fixed: library folders left behind keeping their libraries in memory --- .../coordinators/tabs/instructions.py | 9 +- .../logic/instruction/library.py | 42 ++++++- .../logic/instruction/library_manager.py | 41 ++++-- src/sampletones_core/library/library.py | 13 +- tests/suite/files.py | 25 ++++ tests/suite/library.py | 1 + .../logic/instruction/test_library_logic.py | 117 +++++++++++++++++- .../logic/instruction/test_library_manager.py | 88 ++++++++++--- .../utils/system/test_filesystem.py | 27 +--- 9 files changed, 296 insertions(+), 67 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index e81f294b9..84dcb5f34 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -148,7 +148,7 @@ def __init__( is_operation_active=is_operation_active, ) self._library_panel.set_collapse_handler(self._on_library_collapse_changed) - self._library_tree_logic.on_lock_state_changed = self._library_panel.set_tree_enabled + self._library_tree_logic.on_lock_state_changed = self._on_library_tree_lock_changed self._library_tree_logic.on_favorite_changed = self._repaint_library_favorites self._library_tree_logic.on_search_update_needed = self._library_panel.update_tree_visibility @@ -374,6 +374,13 @@ def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None: """Persists a center-column card's collapsed state so it restores on the next launch.""" self._session_manager.set_card_collapsed(card_tag, collapsed) + def _on_library_tree_lock_changed(self, is_unlocked: bool) -> None: + """Enables the catalog's tree along with its lock, and loads a folder's remembered library once + the lock is let go.""" + self._library_panel.set_tree_enabled(is_unlocked) + if is_unlocked: + self._library_logic.reload_remembered_library() + def _repaint_library_favorites(self, node: FileSystemNode) -> None: """Repaints the row whose star was toggled: the catalog lists a library once, so it is one row.""" self._library_panel.update_favorite_indicators((node,)) diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py index e6ed8bdc7..c909760de 100644 --- a/src/sampletones_application/logic/instruction/library.py +++ b/src/sampletones_application/logic/instruction/library.py @@ -46,6 +46,7 @@ from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin from sampletones_shared.utils.system.filesystem import remove_path +from sampletones_shared.utils.system.paths import is_same_path OnLoadInstructionCallback = Callable[[InstructionUnion], None] OnApplyLibraryConfigCallback = Callable[[InstructionLibraryKey, Optional[InstructionsLibraryConfig]], None] @@ -71,6 +72,7 @@ def __init__( self._library_manager = library_manager self._is_operation_active = is_operation_active self._eta_estimator: Optional[ETAEstimator] = None + self._remembered_key: Optional[InstructionLibraryKey] = None self._lock_function: Optional[VoidCallback] = None self._unlock_function: Optional[VoidCallback] = None @@ -146,24 +148,56 @@ def rebuild_tree(self) -> None: self._library_manager.rebuild_tree() def refresh_libraries(self, load_if_needed: bool = True) -> None: - self._library_manager.set_library_directory(self._config_manager.get_library_directory()) + """Reads the catalog of the configuration's library directory and asks for its tree. + + A directory the catalog moves to brings back the library it had taken up when the reader + left it, which :meth:`reload_remembered_library` loads again. + """ + if self._library_manager.set_library_directory(self._config_manager.get_library_directory()): + self._remembered_key = self._library_manager.current_library_key + self._library_manager.gather_available_libraries() self._sync_with_config_key(load_if_needed=load_if_needed) + self.reload_remembered_library() self.call(self.on_rebuild_tree_needed) def follow_config(self) -> None: """Follows a configuration change, reading the catalog afresh where the change names another library directory and repainting the status otherwise. - A generation writes into the catalog it was started in, so the catalog follows a change at - once, whatever is running; the tree it lists is drawn once the generation lets its lock go. + Another spelling of the folder the catalog stands at, a link to it included, names the same + folder, so the catalog stays as it is. A generation writes into the catalog it was started + in, so the catalog follows a change at once, whatever is running; the tree it lists is drawn + once the generation lets its lock go. """ - if self._library_manager.library_directory != self._config_manager.get_library_directory(): + if not is_same_path(self._library_manager.library_directory, self._config_manager.get_library_directory()): self.refresh_libraries(load_if_needed=False) return self.update_status() + def reload_remembered_library(self) -> None: + """Loads again the library a directory had taken up when the reader left it, once the reader is + back and the tree stands free. + + The load is the one a Load runs, so a failure, such as a file removed meanwhile, is reported + the same way, and the directory lets that choice go. The library loads only while it is still + the directory's choice. A generation or a rebuild holding the tree's lock keeps the load + waiting, and the coordinator calls this again once the lock is let go. + """ + key = self._remembered_key + if key is None or self._is_locked: + return + + self._remembered_key = None + if self.current_library_key != key or self._library_manager.is_library_loaded(key): + return + + if self._load_library(key) is None: + self._library_manager.clear_current_library() + + self.update_status() + def remove_library(self, library_key: InstructionLibraryKey) -> Path: filepath = self._library_manager.get_path(library_key) remove_path(filepath) diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index 03a812caf..237532115 100644 --- a/src/sampletones_application/logic/instruction/library_manager.py +++ b/src/sampletones_application/logic/instruction/library_manager.py @@ -1,3 +1,4 @@ +import threading from dataclasses import dataclass from functools import partial from pathlib import Path @@ -42,7 +43,11 @@ @dataclass class _Catalog: - """The libraries one directory holds in memory, and the one taken up as current there.""" + """The libraries one directory holds in memory, and the one taken up as current there. + + Only the directory the catalog stands at holds libraries in memory. Every directory keeps its + choice of current library. + """ library: InstructionLibrary current_key: Optional[InstructionLibraryKey] @@ -58,6 +63,7 @@ def __init__( self._language_manager = language_manager self._config_manager = config_manager self._catalogs: Dict[Path, _Catalog] = {} + self._catalog_lock = threading.Lock() self._catalog = self._catalog_at(config_manager.get_library_directory()) self._listed_libraries: Dict[InstructionLibraryKey, bool] = {} @@ -75,17 +81,26 @@ def __init__( def library_directory(self) -> Path: return to_path(self._catalog.library.directory) - def set_library_directory(self, directory: Path) -> None: - """Roots the catalog at ``directory``, keeping what every directory has loaded. + def set_library_directory(self, directory: Path) -> bool: + """Roots the catalog at ``directory``, and answers whether that moved it to another folder. - A directory read before brings back the libraries it loaded and the one it had taken up as - current, so a reader pointing the catalog away and back finds it as they left it. + The folder left lets go of the libraries it loaded and keeps the one it had taken up as + current, so a reader coming back finds that choice again, for the caller to load. Every + spelling of one folder, a link to it included, names the same catalog. """ - self._catalog = self._catalog_at(directory) + catalog = self._catalog_at(directory) + with self._catalog_lock: + if catalog is self._catalog: + return False + + self._catalog.library.data.clear() + self._catalog = catalog + + return True def _catalog_at(self, directory: Path) -> _Catalog: """The catalog of ``directory``, started empty the first time the directory is read.""" - root = to_path(directory) + root = to_path(directory).resolve() catalog = self._catalogs.get(root) if catalog is None: catalog = _Catalog( @@ -225,19 +240,23 @@ def _complete_generation( catalog: _Catalog, result: Tuple[InstructionLibraryKey, InstructionLibraryData], ) -> None: - """Writes the generated library into ``catalog``, the one the generation was started in. + """Writes the generated library into ``catalog``, the one the generation was started in, and + makes it that catalog's current library. - The library becomes the current one only where the catalog still stands there. + The library stays in memory only where the catalog still stands there. A catalog left holds + the file and the choice, and the reader coming back loads it from the file. """ key, library_data = result try: - catalog.library.save_data(key, library_data) + catalog.library.write_data(key, library_data) except OSError as exception: self.call(self.on_generation_error, exception) raise - if catalog is self._catalog: + with self._catalog_lock: catalog.current_key = key + if catalog is self._catalog: + catalog.library.data[key] = library_data self.call(self.on_generation_completed) diff --git a/src/sampletones_core/library/library.py b/src/sampletones_core/library/library.py index b38c775fe..6d1309ea1 100644 --- a/src/sampletones_core/library/library.py +++ b/src/sampletones_core/library/library.py @@ -118,15 +118,24 @@ def get_path(self, key: InstructionLibraryKey) -> Path: return Path(self.directory) / key.filename def save_data(self, key: InstructionLibraryKey, library_data: InstructionLibraryData) -> None: - """Caches a library and writes it to its file on disk. + """Writes a library to its file on disk and caches it once the file is written. Args: key: The key identifying the library. library_data: The library to cache and persist. """ + self.write_data(key, library_data) + self.data[key] = library_data + + def write_data(self, key: InstructionLibraryKey, library_data: InstructionLibraryData) -> None: + """Writes a library to its file on disk, creating the library directory where it is missing. + + Args: + key: The key identifying the library. + library_data: The library to persist. + """ path = self.get_path(key) path.parent.mkdir(parents=True, exist_ok=True) - self.data[key] = library_data library_data.save(path) def load_data(self, key: InstructionLibraryKey) -> None: diff --git a/tests/suite/files.py b/tests/suite/files.py index 93c59b241..4b498db6c 100644 --- a/tests/suite/files.py +++ b/tests/suite/files.py @@ -1,5 +1,6 @@ import os import sys +import tempfile from contextlib import contextmanager from pathlib import Path from typing import Final, Iterator @@ -17,6 +18,30 @@ ) +def _symlinks_are_permitted() -> bool: + """Reports whether this machine lets an unprivileged process create a symlink. + + Windows grants the privilege only under Developer Mode or elevation, so the probe + creates one in a throwaway directory and reads the answer from the attempt. + """ + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + try: + (root / "probe").symlink_to(root, target_is_directory=True) + except OSError: + return False + + return True + + +SYMLINKS_PERMITTED: Final[bool] = _symlinks_are_permitted() + +requires_symlinks = pytest.mark.skipif( + not SYMLINKS_PERMITTED, + reason="creating a symlink requires a privilege this machine withholds", +) + + def empty_file(directory: Path, name: str) -> Path: """Writes an empty file called ``name`` under ``directory``, which a check that reads only names and presence accepts.""" path = directory / name diff --git a/tests/suite/library.py b/tests/suite/library.py index 7fe821c50..5dc7e0d02 100644 --- a/tests/suite/library.py +++ b/tests/suite/library.py @@ -14,6 +14,7 @@ from sampletones_tools.corpus.catalog import CHANNELS, build_mini_library OTHER_LIBRARIES: Final[str] = "other_libraries" +LINKED_LIBRARIES: Final[str] = "linked_libraries" class WrittenLibrary: diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py index b4f5c8b44..765cbfa3b 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py @@ -1,7 +1,7 @@ import threading from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, Final, List +from typing import Any, Callable, Dict, Final, List, Optional from unittest.mock import MagicMock, patch import pytest @@ -14,6 +14,7 @@ InstructionsLibraryManager, ) from sampletones_application.paths import LANG_EN +from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.view_model.instruction.library import ( LibraryPanelViewModel, ) @@ -35,8 +36,9 @@ from tests.suite.application import HeldQueue from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.files import requires_symlinks from tests.suite.language import FakeLanguageManager -from tests.suite.library import OTHER_LIBRARIES, WrittenLibrary, aim_library_directory +from tests.suite.library import LINKED_LIBRARIES, OTHER_LIBRARIES, WrittenLibrary, aim_library_directory LOAD_ERROR_KEY: Final[str] = "instructions.library.message.status_load_error" FILE_NOT_FOUND_KEY: Final[str] = "instructions.library.message.status_file_not_found" @@ -340,16 +342,23 @@ def test_update_status_reports_an_existing_unloaded_library(self) -> None: class TreeLock: - """The lock the catalog's tree shares with a generation, counted the way the tree counts it.""" + """The lock the catalog's tree shares with a generation, counted the way the tree counts it. + + Its release reaches ``on_released`` through the render loop's queue, as the tree's lock state does. + """ def __init__(self) -> None: self.holders = 0 + self.on_released: Optional[Callable[[], None]] = None def lock(self) -> None: self.holders += 1 def unlock(self) -> None: + was_held = self.holders > 0 self.holders = max(0, self.holders - 1) + if was_held and self.holders == 0 and self.on_released is not None: + CallbackQueue.add(self.on_released) def locked(self) -> bool: return self.holders > 0 @@ -427,6 +436,7 @@ def catalog( ) lock = TreeLock() logic.configure_lock(lock.lock, lock.unlock, lock.locked) + lock.on_released = logic.reload_remembered_library config_manager.add_config_change_callback(logic.follow_config) catalog = Catalog(logic=logic, manager=manager, config_manager=config_manager, queue=held_queue, lock=lock) logic.on_rebuild_tree_needed = lambda: catalog.rebuilds_under_lock.append(lock.locked()) @@ -464,6 +474,17 @@ def _write_library( return key +def _opened_library(catalog: Catalog) -> InstructionLibraryKey: + """Writes the library the configuration names into the catalog's folder and opens it as Load does.""" + key = _write_library( + catalog.manager.library_directory, + catalog.config_manager.config.library, + SAMPLETONES_LIBRARY_DATA_VERSION, + ) + catalog.logic.load_library_and_set_current(key) + return key + + def _complete(catalog: Catalog) -> None: catalog.write_library() @@ -554,19 +575,103 @@ def test_the_same_directory_repaints_over_what_is_loaded(self, catalog: Catalog) assert catalog.rebuilds_under_lock == [] assert catalog.views[-1].generate_button_label == "Regenerate" - def test_a_directory_pointed_away_from_and_back_repaints_its_library_loaded( + def test_a_folder_pointed_away_from_and_back_loads_its_library_again( self, catalog: Catalog, tmp_path: Path, ) -> None: + """The folder left lets its library go, and the way back loads the library it had open.""" ours = catalog.config_manager.get_library_directory() - catalog.manager._catalog.library.save_data(catalog.config_manager.key, WrittenLibrary()) + key = _opened_library(catalog) aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) away = catalog.views[-1].generate_button_label aim_library_directory(catalog.config_manager, ours) - assert (away, catalog.views[-1].generate_button_label) == ("Generate", "Regenerate") + assert (away, catalog.manager.is_library_loaded(key), catalog.views[-1].generate_button_label) == ( + "Generate", + True, + "Regenerate", + ) + + @requires_symlinks + def test_a_link_to_the_same_folder_repaints_over_what_is_loaded( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """A link names the folder the catalog stands at, so the catalog keeps what it loaded.""" + key = _opened_library(catalog) + link = tmp_path / LINKED_LIBRARIES + link.symlink_to(catalog.config_manager.get_library_directory(), target_is_directory=True) + rebuilds = len(catalog.rebuilds_under_lock) + + aim_library_directory(catalog.config_manager, link) + + assert (len(catalog.rebuilds_under_lock), catalog.manager.is_library_loaded(key)) == (rebuilds, True) + assert catalog.views[-1].generate_button_label == "Regenerate" + + def test_a_library_whose_file_left_while_away_is_reported_on_the_way_back( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """The way back loads the library as a Load does, so a file gone meanwhile is reported, once, and + the folder's choice goes with it.""" + ours = catalog.config_manager.get_library_directory() + key = _opened_library(catalog) + path = catalog.manager.get_path(key) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + path.unlink() + + aim_library_directory(catalog.config_manager, ours) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + aim_library_directory(catalog.config_manager, ours) + + assert (catalog.missing, catalog.manager.is_library_loaded(key), catalog.manager.current_library_key) == ( + [path], + False, + None, + ) + + def test_a_library_generated_while_away_is_written_held_nowhere_and_loaded_on_the_way_back( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + ours = catalog.config_manager.get_library_directory() + started_in = catalog.manager._catalog + catalog.start_generation() + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + + catalog.write_library() + catalog.queue.drain() + held_while_away = dict(started_in.library.data) + aim_library_directory(catalog.config_manager, ours) + + assert ( + (ours / catalog.config_manager.key.filename).exists(), + held_while_away, + catalog.manager.is_library_loaded(catalog.config_manager.key), + ) == (True, {}, True) + + def test_a_way_back_during_a_generation_loads_once_the_tree_stands_free( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """A generation holds the tree, so the way back waits for it before loading the folder's library.""" + ours = catalog.config_manager.get_library_directory() + key = _opened_library(catalog) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + catalog.start_generation() + + aim_library_directory(catalog.config_manager, ours) + during = catalog.manager.is_library_loaded(key) + catalog.write_library() + catalog.queue.drain() + + assert (during, catalog.manager.is_library_loaded(key)) == (False, True) @ENDINGS def test_a_change_during_a_generation_stands_whatever_the_generation_came_to( diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_manager.py b/tests/unit/sampletones_application/logic/instruction/test_library_manager.py index 935c022bd..2d7d4c7ce 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_manager.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_manager.py @@ -16,7 +16,8 @@ from sampletones_core.library import InstructionLibraryKey, LibraryState from sampletones_core.structures.tree import LibraryNode from tests.suite.compatibility import LIBRARY_VERSION, archived -from tests.suite.library import OTHER_LIBRARIES, WrittenLibrary, write_empty_library +from tests.suite.files import requires_symlinks +from tests.suite.library import LINKED_LIBRARIES, OTHER_LIBRARIES, WrittenLibrary, write_empty_library @pytest.fixture @@ -114,8 +115,8 @@ def test_a_library_another_version_built_is_marked_and_holds_no_generators( class TestTheDirectoryTheCatalogStandsAt: - """Each directory keeps the libraries it loaded and the one it took up, however often the catalog - moves away and back.""" + """The folder the catalog stands at keeps the libraries it loaded. A folder left lets them go and keeps + the one it took up, so the reader coming back finds that choice again.""" @staticmethod def _loaded_here(library_manager: InstructionsLibraryManager, key: InstructionLibraryKey) -> None: @@ -130,9 +131,9 @@ def test_the_directory_it_stands_at_keeps_what_it_loaded( ) -> None: self._loaded_here(library_manager, config_manager.key) - library_manager.set_library_directory(config_manager.get_library_directory()) + moved = library_manager.set_library_directory(config_manager.get_library_directory()) - assert library_manager.is_library_loaded(config_manager.key) is True + assert (moved, library_manager.is_library_loaded(config_manager.key)) == (False, True) def test_another_directory_starts_with_nothing_loaded_or_taken_up( self, @@ -143,32 +144,38 @@ def test_another_directory_starts_with_nothing_loaded_or_taken_up( self._loaded_here(library_manager, config_manager.key) other = tmp_path / OTHER_LIBRARIES - library_manager.set_library_directory(other) + moved = library_manager.set_library_directory(other) assert ( + moved, library_manager.library_directory, library_manager.is_library_loaded(config_manager.key), library_manager.current_library_key, - ) == (other, False, None) + ) == (True, other, False, None) - def test_pointing_away_and_back_finds_the_library_loaded_and_taken_up( + def test_pointing_away_lets_the_data_go_and_keeps_the_choice( self, config_manager: ConfigManager, library_manager: InstructionsLibraryManager, tmp_path: Path, ) -> None: + """A folder left holds no library in memory, and the reader coming back finds its choice standing + unloaded, for the catalog's logic to load again.""" ours = config_manager.get_library_directory() self._loaded_here(library_manager, config_manager.key) + left = library_manager._catalog library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) library_manager.gather_available_libraries() + held_while_away = dict(left.library.data) library_manager.set_library_directory(ours) library_manager.gather_available_libraries() - assert (library_manager.is_library_loaded(config_manager.key), library_manager.current_library_key) == ( - True, - config_manager.key, - ) + assert ( + held_while_away, + library_manager.is_library_loaded(config_manager.key), + library_manager.current_library_key, + ) == ({}, False, config_manager.key) def test_what_the_other_directory_took_up_stays_with_it( self, @@ -208,6 +215,46 @@ def test_a_library_whose_file_left_while_away_is_let_go( assert library_manager.is_library_loaded(config_manager.key) is False +class TestAnotherSpellingOfTheFolder: + """Two spellings of one folder name one catalog, so pointing the catalog at another spelling keeps + what the folder loaded and moves nowhere.""" + + @staticmethod + def _respelled_keeps_what_it_loaded( + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + spelling: Path, + ) -> None: + _create_library_file(library_manager, config_manager.key) + library_manager.load_library(config_manager.key) + + moved = library_manager.set_library_directory(spelling) + + assert (moved, library_manager.is_library_loaded(config_manager.key)) == (False, True) + + def test_a_detour_through_the_parent( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + ) -> None: + folder = config_manager.get_library_directory() + detour = folder.parent / ".." / folder.parent.name / folder.name + + self._respelled_keeps_what_it_loaded(config_manager, library_manager, detour) + + @requires_symlinks + def test_a_link_to_the_folder( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + link = tmp_path / LINKED_LIBRARIES + link.symlink_to(config_manager.get_library_directory(), target_is_directory=True) + + self._respelled_keeps_what_it_loaded(config_manager, library_manager, link) + + class TestCompleteGeneration: """A failed library save is an operational failure the user must see. @@ -221,7 +268,7 @@ def test_file_error_reports_and_reraises( library_manager: InstructionsLibraryManager, ) -> None: catalog = MagicMock() - catalog.library.save_data.side_effect = PermissionError("save failed") + catalog.library.write_data.side_effect = PermissionError("save failed") error_callback = MagicMock() completed_callback = MagicMock() library_manager.on_generation_error = error_callback @@ -238,7 +285,7 @@ def test_unexpected_error_propagates_directly( library_manager: InstructionsLibraryManager, ) -> None: catalog = MagicMock() - catalog.library.save_data.side_effect = RuntimeError("unexpected") + catalog.library.write_data.side_effect = RuntimeError("unexpected") error_callback = MagicMock() library_manager.on_generation_error = error_callback @@ -278,21 +325,26 @@ def test_a_library_lands_where_its_generation_started( None, ) - def test_a_library_generated_while_away_stands_loaded_on_the_way_back( + def test_a_library_generated_while_away_is_written_and_chosen_and_held_nowhere( self, config_manager: ConfigManager, library_manager: InstructionsLibraryManager, tmp_path: Path, ) -> None: - """The generation's catalog is the one its directory brings back, so the library is held there.""" + """The folder the generation started in is left, so it keeps the library's file and its choice and + no data, and the reader coming back finds the library chosen there.""" ours = config_manager.get_library_directory() started_in = library_manager._catalog library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) - library_manager._complete_generation(started_in, (config_manager.key, WrittenLibrary())) + library_manager._complete_generation(started_in, (config_manager.key, WrittenLibrary())) library_manager.set_library_directory(ours) - assert library_manager.is_library_loaded(config_manager.key) is True + assert ( + started_in.library.get_path(config_manager.key).exists(), + started_in.library.data, + library_manager.current_library_key, + ) == (True, {}, config_manager.key) class TestTheLibraryAConversionWaitsFor: diff --git a/tests/unit/sampletones_shared/utils/system/test_filesystem.py b/tests/unit/sampletones_shared/utils/system/test_filesystem.py index d682af546..3f766c75f 100644 --- a/tests/unit/sampletones_shared/utils/system/test_filesystem.py +++ b/tests/unit/sampletones_shared/utils/system/test_filesystem.py @@ -1,29 +1,9 @@ -import tempfile from pathlib import Path -from typing import Final import pytest from sampletones_shared.utils.system.filesystem import remove_path - - -def _symlinks_are_permitted() -> bool: - """Reports whether this machine lets an unprivileged process create a symlink. - - Windows grants the privilege only under Developer Mode or elevation, so the probe - creates one in a throwaway directory and reads the answer from the attempt. - """ - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - try: - (root / "probe").symlink_to(root, target_is_directory=True) - except OSError: - return False - - return True - - -SYMLINKS_PERMITTED: Final[bool] = _symlinks_are_permitted() +from tests.suite.files import requires_symlinks class TestRemovePath: @@ -47,10 +27,7 @@ def test_removes_directory_recursively(self, tmp_path: Path) -> None: assert removed == target assert not target.exists() - @pytest.mark.skipif( - not SYMLINKS_PERMITTED, - reason="creating a symlink requires a privilege this machine withholds", - ) + @requires_symlinks def test_removes_directory_symlink_without_touching_target( self, tmp_path: Path, From e409fd672d2d8d561586c1c91871cc080f8753c7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 15:48:11 +0200 Subject: [PATCH 50/84] Changed: the channel aliases to Ctrl+1 to Ctrl+4, with a text field keeping the Alt chords that type and yielding Super commands --- docs/development/application/keyboard.md | 4 +- docs/development/bugs-and-todos.md | 2 + docs/guide/reconstruction.md | 3 +- docs/guide/sequencer.md | 2 +- .../utils/gui/keyboard/focus/consumption.py | 71 ++++++---- .../utils/gui/keyboard/keys.py | 33 +++++ .../utils/gui/keyboard/modifiers.py | 1 + .../keybindings/default.yaml | 8 +- src/sampletones_config/keybindings/macos.yaml | 8 +- .../instruments/test_note_keys.py | 112 ++++++++++++++- .../sequencer/song/test_channel_keys.py | 67 +++++++++ .../sequencer/tracker/test_channel_keys.py | 73 ++++++---- tests/suite/screens/keyboard.py | 6 + tests/suite/screens/screen.py | 6 +- tests/suite/screens/steps/sequencer.py | 5 + .../gui/keyboard/focus/test_consumption.py | 129 +++++++++++++++++- 16 files changed, 465 insertions(+), 65 deletions(-) create mode 100644 tests/screens/sequencer/song/test_channel_keys.py diff --git a/docs/development/application/keyboard.md b/docs/development/application/keyboard.md index 6a8bb2c99..6e0872155 100644 --- a/docs/development/application/keyboard.md +++ b/docs/development/application/keyboard.md @@ -38,7 +38,9 @@ Whether a text or value field keeps a plain key for itself is one router query, The query resolves the focused item to the field behind it. A `dpg.group` reports the state of the widget inside it, and DearPyGui names the outermost such group as the focused item. The instruments panel's sequence input, laid out beside its copy button inside a card body group, reaches the keyboard as that group. An active group therefore answers with the field being edited below it. The query follows the one branch that reports focus, so a panel-spanning group costs a key press only the path down to its field. -**Focus is claimed per key.** A focused input keeps the keys it genuinely consumes and yields the rest. A text or number field consumes `Space` and `Shift+Space`, because space is a character it types, and `Escape`, which cancels the field. Those keys serve the field while it holds focus. A modified combination stays global and fires from anywhere, which is why playing from the shown frame works while typing. Playing from the cursor row belongs to the grid: the sequencer grid claims it while the grid itself holds the keyboard. +**Focus is claimed per key.** A focused input keeps the keys it genuinely consumes and yields the rest. A text or number field consumes `Space` and `Shift+Space`, because space is a character it types, and `Escape`, which cancels the field. Those keys serve the field while it holds focus. + +A command, a combination held with Ctrl or Super, fires from anywhere, which is why playing from the shown frame works while typing. A text field keeps only the commands that edit its text: select all, copy, cut, paste, undo and redo, spelled with Ctrl and with Super. Alt is a typing key as well as a modifier: AltGr and Option type characters through it, and Windows reports AltGr as Ctrl+Alt. A text field therefore keeps an Alt combination on a key that types a character, while one on a function, caret or editing key fires, so `Alt+F4` and `Alt+Up` reach their actions from a field. An action meant to fire while the reader types takes Ctrl or Super. Playing from the cursor row belongs to the grid: the sequencer grid claims it while the grid itself holds the keyboard. **Interactive widgets release the keyboard.** A selectable cell or a transport button hands focus back after its click, so the next playback key reaches the router. `Space` and `Escape` therefore stay live in the moment after any click. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 0e229d463..07a9aa1e5 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -107,6 +107,8 @@ dimension the import starts carrying. silences a channel or lets a recording go is reversible only by reloading the file. * In-application console * Improve performance of the browser's favorite scan of the entire tree per click +* The Command key on macOS. The macOS scheme and the text-field rule read Command as Super, and whether + DearPyGui reports it as Super there or swaps it with Ctrl is unverified on a Mac. ## Architecture diff --git a/docs/guide/reconstruction.md b/docs/guide/reconstruction.md index 3df2110de..73128fbd0 100644 --- a/docs/guide/reconstruction.md +++ b/docs/guide/reconstruction.md @@ -25,8 +25,7 @@ compare the two. Its **NES frequency** field retimes the reconstruction: type a rate. It is also locked for a reconstruction whose file is gone, until you save it to a file. The **Waveform** card has a checkbox for each channel, and keys `1` to `4` switch the same checkboxes. -While an instrument is open, `2` and `3` play notes, so use `Alt+1` to `Alt+4`, which switch the -checkboxes at any time. +`Ctrl+1` to `Ctrl+4` switch them too, also while you type in a field. Click the waveform to play from that point. While playback is paused, a click moves the playback position. Drag the waveform to move the view, and double-click to fit the view. Scroll to zoom, hold **Alt** and diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 1a853c26b..5ed1225eb 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -144,7 +144,7 @@ it. The channel names in the **Order** grid work the same way. **Playback ▸ Channels** shows which channels play. **Unmute all channels** unmutes all four. Keys `1` to `4` mute and unmute a channel when the cursor is outside the grids. Inside the grids, the -digit keys type values, and `Alt+1` to `Alt+4` mute and unmute the channels instead. `Alt+1` to `Alt+4` +digit keys type values, and `Ctrl+1` to `Ctrl+4` mute and unmute the channels instead. `Ctrl+1` to `Ctrl+4` work outside the grids too. Muting changes only what you hear. Saving, exporting, rendering and undo use every channel. Opening, diff --git a/src/sampletones_application/utils/gui/keyboard/focus/consumption.py b/src/sampletones_application/utils/gui/keyboard/focus/consumption.py index 4634ab14d..faf379a5b 100644 --- a/src/sampletones_application/utils/gui/keyboard/focus/consumption.py +++ b/src/sampletones_application/utils/gui/keyboard/focus/consumption.py @@ -3,8 +3,15 @@ import dearpygui.dearpygui as dpg from sampletones_application.utils.gui.keyboard.focus.kind import FieldKind -from sampletones_application.utils.gui.keyboard.keys import FUNCTION_KEYS -from sampletones_application.utils.gui.keyboard.modifiers import Modifier, ModifierSet +from sampletones_application.utils.gui.keyboard.keys import CHARACTER_KEYS, FUNCTION_KEYS +from sampletones_application.utils.gui.keyboard.modifiers import ( + CTRL, + CTRL_SHIFT, + SUPER, + SUPER_SHIFT, + Modifier, + ModifierSet, +) EDITING_KEYS: Final[FrozenSet[int]] = frozenset( { @@ -25,18 +32,23 @@ NO_KEYS: Final[FrozenSet[int]] = frozenset() +TEXT_EDIT_KEYS: Final[FrozenSet[int]] = frozenset( + { + dpg.mvKey_A, + dpg.mvKey_C, + dpg.mvKey_V, + dpg.mvKey_X, + dpg.mvKey_Z, + dpg.mvKey_Y, + } +) +REDO_KEYS: Final[FrozenSet[int]] = frozenset({dpg.mvKey_Z}) + TEXT_EDIT_CHORDS: Final[Dict[ModifierSet, FrozenSet[int]]] = { - frozenset({Modifier.CTRL}): frozenset( - { - dpg.mvKey_A, - dpg.mvKey_C, - dpg.mvKey_V, - dpg.mvKey_X, - dpg.mvKey_Z, - dpg.mvKey_Y, - } - ), - frozenset({Modifier.CTRL, Modifier.SHIFT}): frozenset({dpg.mvKey_Z}), + CTRL: TEXT_EDIT_KEYS, + CTRL_SHIFT: REDO_KEYS, + SUPER: TEXT_EDIT_KEYS, + SUPER_SHIFT: REDO_KEYS, } @@ -44,25 +56,38 @@ def field_consumes_key(kind: FieldKind, key: int, modifiers: ModifierSet) -> boo """Whether a focused field of ``kind`` acts on this key, so a matching shortcut yields to it. A field keeps the keys it uses and lets the rest reach the shortcut. Plain characters and the - caret, commit, and cancel keys belong to whichever field is focused; a text-entry field also - keeps the chords the modifiers name it, so Ctrl+Z undoes and Ctrl+Shift+Z redoes the text. - Every other combination passes through: command chords such as Ctrl+Space, the Ctrl+Shift - combinations a text field has no use for, Alt shortcuts, and the function keys, so intentional - playback and Stop stay reachable while a field is focused. + caret, commit, and cancel keys belong to whichever field is focused. + + A command chord, one held with Ctrl or Super, reaches the shortcuts, so Ctrl+Space plays and + Cmd+S saves from a field. A text-entry field keeps the text-edit chords alone: select all, + copy, cut, paste, undo and redo, spelled with Ctrl and with Super, the key macOS spells them + with. Super decides first, so Cmd+Option+S is a command as well. + + A text-entry field also keeps an Alt chord on a key that types a character, because AltGr and + Option type characters that way: Linux reports AltGr as Alt and Windows as Ctrl+Alt. An Alt + chord on a function, caret or editing key reaches the shortcuts, so Alt+F4 and Alt+Up stay + reachable while a field is focused. """ if kind is FieldKind.NONE: return False + if Modifier.SUPER in modifiers: + return _keeps_text_edit_chord(kind, key, modifiers) + if Modifier.ALT in modifiers: - return False + return kind is FieldKind.TEXT_ENTRY and key in CHARACTER_KEYS if Modifier.CTRL in modifiers: - return kind is FieldKind.TEXT_ENTRY and key in TEXT_EDIT_CHORDS.get( - modifiers, - NO_KEYS, - ) + return _keeps_text_edit_chord(kind, key, modifiers) if key in EDITING_KEYS: return True return kind is FieldKind.TEXT_ENTRY and key not in FUNCTION_KEYS + + +def _keeps_text_edit_chord(kind: FieldKind, key: int, modifiers: ModifierSet) -> bool: + return kind is FieldKind.TEXT_ENTRY and key in TEXT_EDIT_CHORDS.get( + modifiers, + NO_KEYS, + ) diff --git a/src/sampletones_application/utils/gui/keyboard/keys.py b/src/sampletones_application/utils/gui/keyboard/keys.py index e0bb26d6b..aeb0e88ef 100644 --- a/src/sampletones_application/utils/gui/keyboard/keys.py +++ b/src/sampletones_application/utils/gui/keyboard/keys.py @@ -34,6 +34,39 @@ FUNCTION_KEYS: Final[FrozenSet[int]] = frozenset(FUNCTION_KEY_NAMES) +PUNCTUATION_KEYS: Final[FrozenSet[int]] = frozenset( + { + dpg.mvKey_Comma, + dpg.mvKey_Period, + dpg.mvKey_Slash, + dpg.mvKey_Backslash, + dpg.mvKey_Open_Brace, + dpg.mvKey_Close_Brace, + KEY_SEMICOLON, + KEY_QUOTE, + KEY_TILDE, + dpg.mvKey_Minus, + KEY_PLUS, + } +) +KEYPAD_CHARACTER_KEYS: Final[FrozenSet[int]] = frozenset(KEYPAD_DIGIT_NAMES) | frozenset( + { + dpg.mvKey_Decimal, + dpg.mvKey_Divide, + dpg.mvKey_Multiply, + dpg.mvKey_Subtract, + dpg.mvKey_Add, + dpg.mvKey_NumPadEqual, + } +) +CHARACTER_KEYS: Final[FrozenSet[int]] = ( + frozenset(LETTER_NAMES) + | frozenset(DIGIT_NAMES) + | PUNCTUATION_KEYS + | KEYPAD_CHARACTER_KEYS + | frozenset({dpg.mvKey_Spacebar}) +) + KEY_DISPLAY_NAMES: Final[Dict[int, str]] = { **LETTER_NAMES, **DIGIT_NAMES, diff --git a/src/sampletones_application/utils/gui/keyboard/modifiers.py b/src/sampletones_application/utils/gui/keyboard/modifiers.py index 4eeae2139..62b897432 100644 --- a/src/sampletones_application/utils/gui/keyboard/modifiers.py +++ b/src/sampletones_application/utils/gui/keyboard/modifiers.py @@ -33,6 +33,7 @@ class Modifier(StrEnum): CTRL_ALT: Final[ModifierSet] = frozenset({Modifier.CTRL, Modifier.ALT}) CTRL_SHIFT: Final[ModifierSet] = frozenset({Modifier.CTRL, Modifier.SHIFT}) CTRL_ALT_SHIFT: Final[ModifierSet] = frozenset({Modifier.CTRL, Modifier.ALT, Modifier.SHIFT}) +SUPER_SHIFT: Final[ModifierSet] = frozenset({Modifier.SUPER, Modifier.SHIFT}) TYPING_MODIFIERS: Final[ModifierSet] = SHIFT MODIFIER_NAMES: Final[Dict[str, Modifier]] = { diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 32522b99c..22be58d6f 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -50,10 +50,10 @@ bindings: FollowPatterns: {combination: "Ctrl+Shift+F"} FollowOff: {combination: "Ctrl+Alt+F"} ToggleLoopSong: {combination: "Ctrl+L"} - ToggleChannelPulse1: {combination: "1", aliases: ["Alt+1"]} - ToggleChannelPulse2: {combination: "2", aliases: ["Alt+2"]} - ToggleChannelTriangle: {combination: "3", aliases: ["Alt+3"]} - ToggleChannelNoise: {combination: "4", aliases: ["Alt+4"]} + ToggleChannelPulse1: {combination: "1", aliases: ["Ctrl+1"]} + ToggleChannelPulse2: {combination: "2", aliases: ["Ctrl+2"]} + ToggleChannelTriangle: {combination: "3", aliases: ["Ctrl+3"]} + ToggleChannelNoise: {combination: "4", aliases: ["Ctrl+4"]} UnmuteAllChannels: {combination: ~} # view diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 35ba8a3d1..0adfb26c1 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -50,10 +50,10 @@ bindings: FollowPatterns: {combination: "Cmd+Shift+F"} FollowOff: {combination: "Cmd+Alt+F"} ToggleLoopSong: {combination: "Cmd+L"} - ToggleChannelPulse1: {combination: "1", aliases: ["Alt+1"]} - ToggleChannelPulse2: {combination: "2", aliases: ["Alt+2"]} - ToggleChannelTriangle: {combination: "3", aliases: ["Alt+3"]} - ToggleChannelNoise: {combination: "4", aliases: ["Alt+4"]} + ToggleChannelPulse1: {combination: "1", aliases: ["Cmd+1"]} + ToggleChannelPulse2: {combination: "2", aliases: ["Cmd+2"]} + ToggleChannelTriangle: {combination: "3", aliases: ["Cmd+3"]} + ToggleChannelNoise: {combination: "4", aliases: ["Cmd+4"]} UnmuteAllChannels: {combination: ~} # view diff --git a/tests/screens/reconstructions/instruments/test_note_keys.py b/tests/screens/reconstructions/instruments/test_note_keys.py index bf66ad506..ec9e0c849 100644 --- a/tests/screens/reconstructions/instruments/test_note_keys.py +++ b/tests/screens/reconstructions/instruments/test_note_keys.py @@ -1,5 +1,5 @@ import operator -from typing import Final +from typing import Callable, Final, List import pytest @@ -9,13 +9,14 @@ 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.dearpygui.keys import IMGUI_DIGIT_ZERO, IMGUI_ESCAPE 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.worlds.recordings import OPEN_RECONSTRUCTION, SONG PIANO_C_SHARP_UP: Final[int] = IMGUI_DIGIT_ZERO + 2 +TOGGLE_PULSE_TWO: Final[ShortcutId] = ShortcutId.TOGGLE_CHANNEL_PULSE_2 class TestTheNoteKeys: @@ -92,3 +93,110 @@ def test_two_lets_pulse_two_go_and_brings_it_back(self, screen: Screen) -> None: ) screen.press_shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_2) screen.expect(lambda: reconstructions.channel_ticked(ChannelName.PULSE2), bool, description="Pulse 2 back") + + +class TestAChannelChordBesideTheNoteKeys: + """With an instrument open, Pulse 2's key plays a note, and its chord plays nothing. + + The waveform shows the instrument alone, so the chord finds no channel to switch. The key sounds a note, + the chord then sounds nothing, and the key pressed once more sounds a note, which is what a chord that + played would have done. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens the song with no reconstruction.""" + return Startup(reconstruction=None, project=SONG) + + def test_the_key_plays_and_the_chord_plays_nothing(self, screen: Screen) -> None: + def sounded_over(press: Callable[[ShortcutId], None]) -> bool: + heard = screen.sound_heard() + press(TOGGLE_PULSE_TWO) + screen.frames(NOTE_FRAMES) + + return screen.sound_heard() > heard + + def the_key_plays_a_note(screen: Screen) -> None: + give_it_a_volume(screen) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + + assert sounded_over(screen.press_shortcut) + + def the_chord_plays_nothing(screen: Screen) -> None: + assert not sounded_over(screen.press_shortcut_alias) + + def the_key_still_plays(screen: Screen) -> None: + assert sounded_over(screen.press_shortcut) + + screen.scenario( + the_key_plays_a_note, + the_chord_plays_nothing, + the_key_still_plays, + leave_letting_the_project_go, + ).run() + + +class TestAChannelChordBesideAField: + """While a field of a reconstruction's instrument is typed in, Pulse 2's key types its digit, and its chord + switches Pulse 2 above the waveform all the same. + + The key types a digit into Pulse 1's Volume field and leaves Pulse 2 ticked. The chord then lets Pulse 2 + go and leaves the field as it read, the chord again brings Pulse 2 back, and the key types once more into + the field, which is where a chord that typed would have written. + """ + + @pytest.fixture + def startup(self) -> Startup: + """The application opens a reconstruction sounding every channel and no project.""" + return Startup(reconstruction=OPEN_RECONSTRUCTION, project=None) + + def test_the_key_types_and_the_chord_switches_pulse_two(self, screen: Screen) -> None: + reconstructions = screen.reconstructions + instruments = reconstructions.instruments + typed: List[str] = [] + + def volume() -> str: + return instruments.envelope(ChannelName.PULSE1, FeatureKey.VOLUME) + + def pulse_two_ticked() -> bool: + return reconstructions.channel_ticked(ChannelName.PULSE2) + + def the_key_types_its_digit(screen: Screen) -> None: + expect_open(screen, OPEN_RECONSTRUCTION) + screen.tabs.bring_to_front(Tab.RECONSTRUCTIONS) + instruments.bring_forward(ChannelName.PULSE1) + field = instruments.field(ChannelName.PULSE1, FeatureKey.VOLUME) + screen.hand.scroll_into_view(field) + screen.hand.click(field) + before = volume() + + screen.press_shortcut(TOGGLE_PULSE_TWO) + + typed.append(screen.expect(volume, before.__ne__, description="a digit typed into the field")) + assert pulse_two_ticked() + + def the_chord_lets_pulse_two_go_and_types_nothing(screen: Screen) -> None: + screen.press_shortcut_alias(TOGGLE_PULSE_TWO) + + screen.expect(pulse_two_ticked, operator.not_, description="Pulse 2 let go") + screen.frames(NOTE_FRAMES) + assert volume() == typed[0] + + def the_chord_brings_it_back(screen: Screen) -> None: + screen.press_shortcut_alias(TOGGLE_PULSE_TWO) + + screen.expect(pulse_two_ticked, bool, description="Pulse 2 back") + + def the_key_types_into_the_field_again(screen: Screen) -> None: + screen.press_shortcut(TOGGLE_PULSE_TWO) + + screen.expect(volume, typed[0].__ne__, description="a second digit typed into the field") + assert pulse_two_ticked() + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + screen.scenario( + the_key_types_its_digit, + the_chord_lets_pulse_two_go_and_types_nothing, + the_chord_brings_it_back, + the_key_types_into_the_field_again, + ).run() diff --git a/tests/screens/sequencer/song/test_channel_keys.py b/tests/screens/sequencer/song/test_channel_keys.py new file mode 100644 index 000000000..310e2b6d8 --- /dev/null +++ b/tests/screens/sequencer/song/test_channel_keys.py @@ -0,0 +1,67 @@ +from functools import partial +from typing import Final, List + +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.song.constants import SETTLING_FRAMES +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import ( + channels_sounding, + on_the_sequencer, + sounding_but, +) + +FIRST_POSITION: Final[int] = 0 +TOGGLE_PULSE_TWO: Final[ShortcutId] = ShortcutId.TOGGLE_CHANNEL_PULSE_2 + + +class TestTheChannelKeysBesideTheOrder: + """With the cursor on an order entry, Pulse 2's chord mutes Pulse 2 and types nothing, while its key types + a digit. + + The chord mutes Pulse 2 and leaves the entry as it read, and the chord again brings it back. The key then + types its digit into the same entry, which is what a chord that typed would have shown. + """ + + def test_the_chord_mutes_pulse_two_and_the_key_types(self, screen: Screen) -> None: + order = screen.sequencer.order + entry: List[str] = [] + + def entry_label() -> str: + return order.label(ChannelName.PULSE2, FIRST_POSITION) + + def the_chord_mutes_pulse_two_and_types_nothing(screen: Screen) -> None: + on_the_sequencer(screen) + entry.append(entry_label()) + order.click(ChannelName.PULSE2, FIRST_POSITION) + + screen.press_shortcut_alias(TOGGLE_PULSE_TWO) + + screen.expect( + partial(channels_sounding, screen), + sounding_but(ChannelName.PULSE2).__eq__, + description="Pulse 2 muted", + ) + screen.frames(SETTLING_FRAMES) + assert entry_label() == entry[0] + + def the_chord_brings_it_back(screen: Screen) -> None: + screen.press_shortcut_alias(TOGGLE_PULSE_TWO) + + screen.expect( + partial(channels_sounding, screen), + sounding_but().__eq__, + description="every channel sounding again", + ) + + def the_key_types_into_the_entry(screen: Screen) -> None: + screen.press_shortcut(TOGGLE_PULSE_TWO) + + screen.expect(entry_label, entry[0].__ne__, description="a digit typed into the entry") + assert channels_sounding(screen) == sounding_but() + + screen.scenario( + the_chord_mutes_pulse_two_and_types_nothing, + the_chord_brings_it_back, + the_key_types_into_the_entry, + ).run() diff --git a/tests/screens/sequencer/tracker/test_channel_keys.py b/tests/screens/sequencer/tracker/test_channel_keys.py index 4266dcc0e..4866ee50e 100644 --- a/tests/screens/sequencer/tracker/test_channel_keys.py +++ b/tests/screens/sequencer/tracker/test_channel_keys.py @@ -1,60 +1,81 @@ from functools import partial from typing import Final, List +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.screens.sequencer.tracker.constants import TYPING_FRAMES -from tests.screens.sequencer.tracker.steps import play_a_note -from tests.suite.screens.dearpygui.keys import IMGUI_DIGIT_ZERO, IMGUI_LEFT_ALT from tests.suite.screens.screen import Screen -from tests.suite.screens.steps.sequencer import channels_sounding, leave_letting_the_project_go, on_the_sequencer +from tests.suite.screens.steps.sequencer import ( + channels_sounding, + leave_letting_the_project_go, + on_the_sequencer, + sounding_but, +) from tests.suite.screens.worlds.songs import PAD_ROW -DIGIT_TWO: Final[int] = IMGUI_DIGIT_ZERO + 2 -EVERY_CHANNEL_SOUNDING: Final[List[bool]] = [True, True, True, True] -PULSE_TWO_MUTED: Final[List[bool]] = [True, False, True, True] +CARET_ROW: Final[int] = PAD_ROW + 1 +TOGGLE_PULSE_TWO: Final[ShortcutId] = ShortcutId.TOGGLE_CHANNEL_PULSE_2 class TestTheChannelKeysBesideTheNotes: - """With the cursor in a pitch column, 2 types the note it names, and Alt+2 mutes Pulse 2 all the same. + """With the cursor in a pitch column, Pulse 2's key types the note it names, and its chord mutes Pulse 2 + all the same. - A note is typed with 2 and the mix stays whole. Alt+2 then mutes Pulse 2 and leaves the note, and Alt+2 - again brings it back. + The key types a note on the Pad row, which moves the caret a row down, and the mix stays whole. The chord + then mutes Pulse 2 and types nothing on the caret's row, and the chord again brings it back. The key + pressed once more types a note on that row, which is where a chord that typed would have written. """ - def test_two_types_a_note_and_alt_two_mutes_pulse_two(self, screen: Screen) -> None: + def test_the_key_types_a_note_and_the_chord_mutes_pulse_two(self, screen: Screen) -> None: tracker = screen.sequencer.tracker - typed: List[str] = [] + caret_row: List[str] = [] - def two_types_a_note(screen: Screen) -> None: + def caret_row_label() -> str: + return tracker.label(CARET_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) + + def the_key_types_a_note(screen: Screen) -> None: on_the_sequencer(screen) before = tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) + tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) - play_a_note(screen, PAD_ROW, ChannelName.PULSE2, DIGIT_TWO) + screen.press_shortcut(TOGGLE_PULSE_TWO) - typed.append(tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE)) - assert typed[0] != before - assert channels_sounding(screen) == EVERY_CHANNEL_SOUNDING + screen.frames(TYPING_FRAMES) + assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) != before + assert channels_sounding(screen) == sounding_but() + caret_row.append(caret_row_label()) - def alt_two_mutes_pulse_two(screen: Screen) -> None: - screen.hand.press_key(DIGIT_TWO, modifiers=[IMGUI_LEFT_ALT]) + def the_chord_mutes_pulse_two_and_types_nothing(screen: Screen) -> None: + screen.press_shortcut_alias(TOGGLE_PULSE_TWO) - screen.expect(partial(channels_sounding, screen), PULSE_TWO_MUTED.__eq__, description="Pulse 2 muted") + screen.expect( + partial(channels_sounding, screen), + sounding_but(ChannelName.PULSE2).__eq__, + description="Pulse 2 muted", + ) screen.frames(TYPING_FRAMES) - assert tracker.label(PAD_ROW, ChannelName.PULSE2, SubColumn.TRANSPOSE) == typed[0] + assert caret_row_label() == caret_row[0] - def alt_two_brings_it_back(screen: Screen) -> None: - screen.hand.press_key(DIGIT_TWO, modifiers=[IMGUI_LEFT_ALT]) + def the_chord_brings_it_back(screen: Screen) -> None: + screen.press_shortcut_alias(TOGGLE_PULSE_TWO) screen.expect( partial(channels_sounding, screen), - EVERY_CHANNEL_SOUNDING.__eq__, + sounding_but().__eq__, description="every channel sounding again", ) + def the_key_types_on_the_caret_row(screen: Screen) -> None: + screen.press_shortcut(TOGGLE_PULSE_TWO) + + screen.expect(caret_row_label, caret_row[0].__ne__, description="a note typed on the caret's row") + assert channels_sounding(screen) == sounding_but() + screen.scenario( - two_types_a_note, - alt_two_mutes_pulse_two, - alt_two_brings_it_back, + the_key_types_a_note, + the_chord_mutes_pulse_two_and_types_nothing, + the_chord_brings_it_back, + the_key_types_on_the_caret_row, leave_letting_the_project_go, ).run() diff --git a/tests/suite/screens/keyboard.py b/tests/suite/screens/keyboard.py index c04e0f4cc..d87957c2c 100644 --- a/tests/suite/screens/keyboard.py +++ b/tests/suite/screens/keyboard.py @@ -7,6 +7,7 @@ LEFT_KEY: Final[int] = 0 PRIMARY_COMBINATION: Final[int] = 0 +FIRST_ALIAS: Final[int] = 0 def primary_combination(shortcut: Shortcut) -> KeyCombination: @@ -14,6 +15,11 @@ def primary_combination(shortcut: Shortcut) -> KeyCombination: return shortcut.combinations()[PRIMARY_COMBINATION] +def first_alias(shortcut: Shortcut) -> KeyCombination: + """The first further combination an action answers to, beside the one it displays.""" + return shortcut.aliases[FIRST_ALIAS] + + def press_combination(hand: Hand, combination: KeyCombination) -> None: """Presses ``combination`` on the real keyboard: its modifiers on the left side, then its key. diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 516bfe298..3711012af 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -38,7 +38,7 @@ 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 -from tests.suite.screens.keyboard import press_combination, primary_combination +from tests.suite.screens.keyboard import first_alias, 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 @@ -289,6 +289,10 @@ 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 press_shortcut_alias(self, shortcut_id: ShortcutId) -> None: + """Presses the first alias the scheme in place gives ``shortcut_id`` on the real keyboard.""" + press_combination(self.hand, first_alias(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( diff --git a/tests/suite/screens/steps/sequencer.py b/tests/suite/screens/steps/sequencer.py index e5af019f7..5d11d7720 100644 --- a/tests/suite/screens/steps/sequencer.py +++ b/tests/suite/screens/steps/sequencer.py @@ -57,6 +57,11 @@ 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_but(*muted: ChannelName) -> List[bool]: + """What Playback ▸ Channels marks while exactly ``muted`` are silenced, in channel order.""" + return [channel not in muted for channel in ChannelName.items()] + + def channels_sounding(screen: Screen) -> List[bool]: """Which channels Playback ▸ Channels marks as sounding, in channel order.""" return [ diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py index 6d5b1f04e..3856df872 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Final import dearpygui.dearpygui as dpg import pytest @@ -10,14 +11,21 @@ from sampletones_application.utils.gui.keyboard.modifiers import ( ALT, CTRL, + CTRL_ALT, CTRL_SHIFT, NO_MODIFIERS, SHIFT, + SUPER, + SUPER_SHIFT, + Modifier, ModifierSet, ) from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +ALT_SHIFT: Final[ModifierSet] = frozenset({Modifier.ALT, Modifier.SHIFT}) +SUPER_ALT: Final[ModifierSet] = frozenset({Modifier.SUPER, Modifier.ALT}) + class TestFieldConsumesKey(BaseTestSuite): @dataclass(frozen=True, kw_only=True) @@ -109,12 +117,124 @@ class TestCase(BaseRegularTestCase): expected=False, ), TestCase( - label="text field yields Alt+F4", + label="text field yields Alt on a function key, so Alt+F4 exits", kind=FieldKind.TEXT_ENTRY, key=dpg.mvKey_F4, modifiers=ALT, expected=False, ), + TestCase( + label="text field yields Alt on a caret key, so Alt+Up reaches its shortcut", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Up, + modifiers=ALT, + expected=False, + ), + TestCase( + label="text field yields Alt on an editing key, so Alt+Home reaches its shortcut", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Home, + modifiers=ALT, + expected=False, + ), + TestCase( + label="text field types AltGr on a digit, which Linux reports as Alt+2", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_2, + modifiers=ALT, + expected=True, + ), + TestCase( + label="text field types AltGr on a letter, which Linux reports as Alt+E", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_E, + modifiers=ALT, + expected=True, + ), + TestCase( + label="text field types a shifted AltGr letter, Alt+Shift+S", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_S, + modifiers=ALT_SHIFT, + expected=True, + ), + TestCase( + label="text field types AltGr on punctuation, Alt+Period", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Period, + modifiers=ALT, + expected=True, + ), + TestCase( + label="text field types AltGr on a letter, which Windows reports as Ctrl+Alt+S", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_S, + modifiers=CTRL_ALT, + expected=True, + ), + TestCase( + label="text field yields Ctrl+Alt on a caret key", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Left, + modifiers=CTRL_ALT, + expected=False, + ), + TestCase( + label="text field yields Super+S, so Cmd+S saves from a field", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_S, + modifiers=SUPER, + expected=False, + ), + TestCase( + label="text field yields Super+Space", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Spacebar, + modifiers=SUPER, + expected=False, + ), + TestCase( + label="text field yields Super+Alt+S, so Cmd+Option+S saves from a field", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_S, + modifiers=SUPER_ALT, + expected=False, + ), + TestCase( + label="text field yields Super+Shift+A", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_A, + modifiers=SUPER_SHIFT, + expected=False, + ), + TestCase( + label="text field copies on Super+C", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_C, + modifiers=SUPER, + expected=True, + ), + TestCase( + label="text field pastes on Super+V", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_V, + modifiers=SUPER, + expected=True, + ), + TestCase( + label="text field undoes on Super+Z", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Z, + modifiers=SUPER, + expected=True, + ), + TestCase( + label="text field redoes on Super+Shift+Z", + kind=FieldKind.TEXT_ENTRY, + key=dpg.mvKey_Z, + modifiers=SUPER_SHIFT, + expected=True, + ), TestCase( label="text field yields F11", kind=FieldKind.TEXT_ENTRY, @@ -140,6 +260,13 @@ class TestCase(BaseRegularTestCase): modifiers=CTRL, expected=False, ), + TestCase( + label="open combo yields Alt+2", + kind=FieldKind.CHOICE, + key=dpg.mvKey_2, + modifiers=ALT, + expected=False, + ), ) @pytest.mark.parametrize( From edb9fdbcac9b815e7782157795d550be7ac6ee28 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 15:51:23 +0200 Subject: [PATCH 51/84] Fixed: the converter hashing its settings once per recording on every gesture --- .../reconstructions/converter/paths/fields.py | 30 +++++++--- .../reconstructions/converter/paths/utils.py | 48 +++++++++++++--- .../reconstructions/converter/plan/batch.py | 35 ++++++------ tests/benchmarks/test_converter_load.py | 30 ++++++++++ .../converter/paths/test_fields.py | 43 +++++++++------ .../converter/paths/test_utils.py | 19 ++++++- .../converter/plan/test_plans.py | 55 ++++++++++++++++++- 7 files changed, 209 insertions(+), 51 deletions(-) diff --git a/src/sampletones_core/reconstructions/converter/paths/fields.py b/src/sampletones_core/reconstructions/converter/paths/fields.py index a7974db2a..c2272b24e 100644 --- a/src/sampletones_core/reconstructions/converter/paths/fields.py +++ b/src/sampletones_core/reconstructions/converter/paths/fields.py @@ -49,8 +49,23 @@ def channels(self) -> Tuple[ChannelName, ...]: def from_config(cls, config: Config, channels: AbstractSet[ChannelName]) -> Self: """The fields a reconstruction's directory is named from: the settings, and the channels it was handed. - The channels come from the setup rather than the configuration, so the name is written in - the order the application states them however the caller gathered the set. + The channels come from the setup, so the name is written in the order the application + states them however the caller gathered the set. + """ + return cls.from_hashed_config(config, channels, settings_hash=cls.settings_hash(config)) + + @classmethod + def from_hashed_config( + cls, + config: Config, + channels: AbstractSet[ChannelName], + *, + settings_hash: str, + ) -> Self: + """The fields :meth:`from_config` names, with the hash of the settings given. + + A caller naming the directories of several channel sets under one configuration hashes the + settings once with :meth:`settings_hash` and names each set from that hash. """ return cls( sr=config.library.sample_rate, @@ -58,9 +73,14 @@ def from_config(cls, config: Config, channels: AbstractSet[ChannelName]) -> Self sm=config.library.spectrum_method, tg=config.library.transformation_gamma, gn=abbreviate_channel_names(ordered_channels(channels)), - ch=hash_models(config.library, config.generation), + ch=settings_hash, ) + @staticmethod + def settings_hash(config: Config) -> str: + """The hash a directory's name carries, folding in the library and generation settings.""" + return hash_models(config.library, config.generation) + @classmethod def from_directory_name(cls, name: str) -> Optional[Self]: """Parses a directory name, returning ``None`` when it is not a config directory. @@ -102,7 +122,3 @@ def display_name(self) -> str: self.gn, ] ) - - @classmethod - def generate_config_directory_name(cls, config: Config, channels: AbstractSet[ChannelName]) -> str: - return cls.from_config(config, channels).directory_name diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index bcb95b738..dc13a9076 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -1,6 +1,6 @@ import os from pathlib import Path -from typing import AbstractSet, Iterator, List, Tuple +from typing import AbstractSet, Dict, FrozenSet, Iterator, List, Tuple from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName @@ -33,17 +33,49 @@ def reconstructions_directory(config: Config) -> Path: return to_path(config.general.reconstructions_directory) +class ConfigDirectories: + """The directories one configuration writes its reconstructions into, one per channel set. + + A directory is named after the settings that shaped the library and the channels the + reconstruction was handed, so reconstructions that differ in either keep apart. The settings' + part of the name, with its hash, is read once when this is built, and each channel set names + its directory the first time it is asked for. A plan reads where its recordings are written + through one of these, so the reading costs one hash, however many recordings it names. + """ + + def __init__(self, config: Config) -> None: + self._config = config + self._root = reconstructions_directory(config) + self._settings_hash = ConfigDirectoryFields.settings_hash(config) + self._named: Dict[FrozenSet[ChannelName], Path] = {} + + @property + def root(self) -> Path: + """The directory holding the directory of every channel set.""" + return self._root + + def directory(self, channels: AbstractSet[ChannelName]) -> Path: + """The directory of the reconstructions handed ``channels``.""" + channel_set = frozenset(channels) + directory = self._named.get(channel_set) + if directory is None: + fields = ConfigDirectoryFields.from_hashed_config( + self._config, + channel_set, + settings_hash=self._settings_hash, + ) + directory = self._root / fields.directory_name + self._named[channel_set] = directory + + return directory + + def config_directory_path( config: Config, channels: AbstractSet[ChannelName], ) -> Path: - """The directory a reconstruction is written into. - - The directory is named after the settings that shaped the library and the channels the - reconstruction was handed, so reconstructions that differ in either keep apart. - """ - config_directory = ConfigDirectoryFields.generate_config_directory_name(config, channels) - return reconstructions_directory(config) / config_directory + """The directory a reconstruction handed ``channels`` is written into, as :class:`ConfigDirectories` names it.""" + return ConfigDirectories(config).directory(channels) def get_output_path( diff --git a/src/sampletones_core/reconstructions/converter/plan/batch.py b/src/sampletones_core/reconstructions/converter/plan/batch.py index 5e9b99ddd..d79310f09 100644 --- a/src/sampletones_core/reconstructions/converter/plan/batch.py +++ b/src/sampletones_core/reconstructions/converter/plan/batch.py @@ -5,10 +5,9 @@ from sampletones_core.configs import Config from sampletones_core.reconstructions.converter.job import ConversionJob from sampletones_core.reconstructions.converter.paths.utils import ( - config_directory_path, + ConfigDirectories, get_relative_path, named_output_path, - reconstructions_directory, ) from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_shared.exceptions import NoFilesToProcessError @@ -34,13 +33,13 @@ def is_named(self) -> bool: """The reader named this recording itself, rather than the folder holding it.""" return self.base_directory is None - def directory(self, config: Config) -> Path: + def directory(self, directories: ConfigDirectories) -> Path: """The folder of the channels this recording is handed, which its reconstruction lands in.""" - return config_directory_path(config, self.stems.covered_channels) + return directories.directory(self.stems.covered_channels) - def output_path(self, config: Config) -> Path: + def output_path(self, directories: ConfigDirectories) -> Path: """The reconstruction this recording is written to.""" - directory = self.directory(config) + directory = self.directory(directories) if self.base_directory is None: return named_output_path(directory, (self.source,)) @@ -67,14 +66,15 @@ def destination(self, config: Config) -> Path: Recordings sharing their channels share one folder, which is that folder; recordings with different channels fill one folder apiece, all held in the reconstructions directory. """ + directories = ConfigDirectories(config) if len(self.entries) == 1: - return self.entries[0].output_path(config) + return self.entries[0].output_path(directories) - directories = {entry.directory(config) for entry in self.entries} - if len(directories) == 1: - return directories.pop() + named = {entry.directory(directories) for entry in self.entries} + if len(named) == 1: + return named.pop() - return reconstructions_directory(config) + return directories.root def jobs(self, config: Config) -> List[ConversionJob]: """The single-source jobs this batch writes. @@ -82,9 +82,11 @@ def jobs(self, config: Config) -> List[ConversionJob]: Raises: NoFilesToProcessError: If every gathered recording is reconstructed already. """ + directories = ConfigDirectories(config) + targets = ((entry, entry.output_path(directories)) for entry in self.entries) jobs = [ ConversionJob(sources=(entry.source,), stems=entry.stems, output_path=output_path) - for entry, output_path in self._targets(config) + for entry, output_path in targets if entry.is_named or not output_path.exists() ] if not jobs: @@ -94,9 +96,6 @@ def jobs(self, config: Config) -> List[ConversionJob]: def existing_targets(self, config: Config) -> Tuple[Path, ...]: """The reconstructions standing where a recording the reader named would be written.""" - return tuple( - output_path for entry, output_path in self._targets(config) if entry.is_named and output_path.is_file() - ) - - def _targets(self, config: Config) -> List[Tuple[BatchEntry, Path]]: - return [(entry, entry.output_path(config)) for entry in self.entries] + directories = ConfigDirectories(config) + named = (entry.output_path(directories) for entry in self.entries if entry.is_named) + return tuple(output_path for output_path in named if output_path.is_file()) diff --git a/tests/benchmarks/test_converter_load.py b/tests/benchmarks/test_converter_load.py index b9bb120bf..4d9024525 100644 --- a/tests/benchmarks/test_converter_load.py +++ b/tests/benchmarks/test_converter_load.py @@ -40,8 +40,11 @@ from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource from sampletones_application.view_model.shared.stems import StemsListViewModel +from sampletones_core.configs import Config from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.converter.paths.utils import config_directory_path +from sampletones_core.reconstructions.converter.plan.batch import BatchConversion from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from tests.suite.base import BaseTestSuite from tests.suite.timing import seconds @@ -50,6 +53,7 @@ LARGE_FOLDER: Final[int] = 10_000 GROWTH_ALLOWANCE: Final[float] = 2.0 UNREADABLE: Final[float] = float("inf") +NAMING_SHARE: Final[float] = 0.1 SETTINGS: Final[StemSettings] = StemSettings(channels=[ChannelName.PULSE1], bends=[]) SMALL_ROOT: Final[Path] = Path("/gathered/small") LARGE_ROOT: Final[Path] = Path("/gathered/large") @@ -189,6 +193,32 @@ def test_it_writes_an_entry_for_every_recording_a_folder_holds(self) -> None: assert entries[0].base_directory == LARGE_ROOT +class TestReadingWhereABatchWrites(BaseTestSuite): + """Every gesture follows the setup to its destination, which reads where every recording is written. + + Recordings sharing their channels share one folder, and the folder's name carries a hash of the + settings. Naming that folder again for each recording costs a hash apiece, which is the shape this + bound catches. A reading of that shape costs the whole of naming a folder for every recording and + more, while one that names a folder per channel set and looks the rest up costs a small share of + it, so ``NAMING_SHARE`` stands between the two. + """ + + def test_it_names_a_folder_per_channel_set(self) -> None: + config = Config() + plan = BatchConversion(entries=batch_entries(state_of(LARGE_ROOT, LARGE_FOLDER))) + channels = plan.entries[0].stems.covered_channels + naming = seconds(lambda: config_directory_path(config, channels)) + reading = seconds(lambda: plan.destination(config)) + report = ( + f"one folder named {naming * 1_000_000:.1f} us, " + f"{LARGE_FOLDER} recordings read {reading * 1000:.1f} ms, " + f"{reading / (naming * LARGE_FOLDER):.3f} of naming a folder for each" + ) + print(report) + + assert reading < naming * LARGE_FOLDER * NAMING_SHARE, report + + class TestSettlingAChannel(BaseTestSuite): """One box on a folder settles every recording it stands for. diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py index a97d4e877..f043962c3 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py @@ -1,3 +1,5 @@ +from typing import FrozenSet + import pytest from sampletones_core.configs import Config @@ -24,44 +26,53 @@ def config() -> Config: return Config() -class TestGenerateConfigDirectoryName: +def _directory_name(config: Config, channels: FrozenSet[ChannelName]) -> str: + return ConfigDirectoryFields.from_config(config, channels).directory_name + + +class TestTheDirectoryNameOfAConfiguration: def test_result_contains_sample_rate(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config, CHANNELS) + name = _directory_name(config, CHANNELS) assert str(config.library.sample_rate) in name def test_result_contains_nes_frequency(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config, CHANNELS) + name = _directory_name(config, CHANNELS) assert str(config.library.nes_frequency) in name def test_same_config_produces_same_name(self, config: Config) -> None: - assert ConfigDirectoryFields.generate_config_directory_name( - config, CHANNELS - ) == ConfigDirectoryFields.generate_config_directory_name(config, CHANNELS) + assert _directory_name(config, CHANNELS) == _directory_name(config, CHANNELS) def test_different_channel_sets_produce_different_names(self, config: Config) -> None: """The directory names what a run hands out, so two sets of channels never share one.""" - assert ConfigDirectoryFields.generate_config_directory_name( - config, CHANNELS - ) != ConfigDirectoryFields.generate_config_directory_name(config, frozenset({ChannelName.PULSE1})) + assert _directory_name(config, CHANNELS) != _directory_name(config, frozenset({ChannelName.PULSE1})) def test_the_name_reads_the_same_however_the_set_was_gathered(self, config: Config) -> None: """A set has no order of its own, so the name states the channels in the app's own order.""" reversed_set = frozenset(reversed(list(DEFAULT_CHANNELS))) - assert ConfigDirectoryFields.generate_config_directory_name( - config, CHANNELS - ) == ConfigDirectoryFields.generate_config_directory_name(config, reversed_set) + assert _directory_name(config, CHANNELS) == _directory_name(config, reversed_set) + + def test_a_hash_taken_once_names_the_same_directory(self, config: Config) -> None: + """A caller naming several channel sets hashes the settings once, and each name reads as the + configuration's own.""" + settings_hash = ConfigDirectoryFields.settings_hash(config) + + assert ConfigDirectoryFields.from_hashed_config( + config, + CHANNELS, + settings_hash=settings_hash, + ) == ConfigDirectoryFields.from_config(config, CHANNELS) class TestConfigDirectoryFields: - def test_round_trips_with_generate_config_directory_name(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config, CHANNELS) + def test_round_trips_with_the_directory_name(self, config: Config) -> None: + name = _directory_name(config, CHANNELS) fields = ConfigDirectoryFields.from_directory_name(name) assert fields is not None assert fields.directory_name == name def test_parses_components(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config, CHANNELS) + name = _directory_name(config, CHANNELS) fields = ConfigDirectoryFields.from_directory_name(name) assert fields is not None assert fields.sr == config.library.sample_rate @@ -71,7 +82,7 @@ def test_parses_components(self, config: Config) -> None: assert fields.channels == tuple(DEFAULT_CHANNELS) def test_directory_name_embeds_field_keys(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config, CHANNELS) + name = _directory_name(config, CHANNELS) segments = name.split("_") assert {"sr", "nf", "sm", "tg", "gn", "ch"}.issubset(segments) diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py index 44a84814c..a84f31c2d 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py @@ -4,15 +4,18 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import DEFAULT_CHANNELS +from sampletones_core.constants.enums import DEFAULT_CHANNELS, ChannelName from sampletones_core.reconstructions.converter.paths import ( + ConfigDirectoryFields, filter_files, get_audio_files, get_output_path, get_relative_path, group_output_path, + reconstructions_directory, walk_entries, ) +from sampletones_core.reconstructions.converter.paths.utils import ConfigDirectories from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from tests.suite.files import LOCKED_FOLDER, held_at, requires_folder_permissions @@ -47,6 +50,20 @@ def test_result_is_absolute(self) -> None: assert result.is_absolute() +class TestConfigDirectories: + """A configuration names one directory per channel set under its reconstructions directory, as + its own fields name it.""" + + def test_each_channel_set_names_its_own_directory(self, config: Config) -> None: + directories = ConfigDirectories(config) + channel_sets = (CHANNELS, frozenset({ChannelName.PULSE1}), CHANNELS) + + assert [directories.directory(channels) for channels in channel_sets] == [ + reconstructions_directory(config) / ConfigDirectoryFields.from_config(config, channels).directory_name + for channels in channel_sets + ] + + class TestGetOutputPath: def test_file_input_returns_path_with_reconstruction_extension( self, diff --git a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py index d7dff04a4..78c7e51c1 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py +++ b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py @@ -1,13 +1,16 @@ +from dataclasses import dataclass from pathlib import Path -from typing import List, Optional +from typing import Callable, Final, List, Optional, Tuple import pytest +from pydantic import BaseModel from sampletones_core.configs import Config from sampletones_core.constants.enums import ( DEFAULT_CHANNELS, ChannelName, ) +from sampletones_core.reconstructions.converter.paths import fields as fields_module from sampletones_core.reconstructions.converter.paths.utils import ( config_directory_path, get_output_path, @@ -19,6 +22,9 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.exceptions import NoFilesToProcessError +from sampletones_shared.utils.hashing import hash_models +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase @pytest.fixture(scope="module") @@ -27,6 +33,7 @@ def config() -> Config: CHANNELS = frozenset(DEFAULT_CHANNELS) +BATCH_RECORDINGS: Final[int] = 6 @pytest.fixture(scope="module") @@ -472,6 +479,52 @@ def test_a_rerun_writes_again_a_recording_whose_channels_changed( ) +class TestReadingABatchHashesItsSettingsOnce(BaseTestSuite): + """A batch names the folder of each channel set from one hash of its settings per reading, however + many recordings it holds.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + read: Callable[[BatchConversion, Config], object] + + test_cases = ( + TestCase(label="where it writes", read=BatchConversion.destination), + TestCase(label="its jobs", read=BatchConversion.jobs), + TestCase(label="the targets standing", read=BatchConversion.existing_targets), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_one_hash_names_every_folder( + self, + config: Config, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + test_case: TestCase, + ) -> None: + hashed: List[Tuple[BaseModel, ...]] = [] + + def counted(*models: BaseModel) -> str: + hashed.append(models) + return hash_models(*models) + + monkeypatch.setattr(fields_module, "hash_models", counted) + sources = _write_audio_files(tmp_path, [f"{index}.wav" for index in range(BATCH_RECORDINGS)]) + channel_sets = ( + StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1])), + StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1, ChannelName.TRIANGLE])), + ) + plan = BatchConversion( + entries=tuple( + BatchEntry(source=source, stems=channel_sets[index % len(channel_sets)], base_directory=None) + for index, source in enumerate(sources) + ) + ) + + test_case.read(plan, config) + + assert len(hashed) == 1 + + class TestEveryJobWritesWhereItsPlanSays: """A plan's destination is the file its one job writes, or the folder every job writes inside.""" From e0348c09be49b16e141cc8f09ce731c9b4edc484 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 15:57:12 +0200 Subject: [PATCH 52/84] Fixed: a run left with one job naming the folder in its status line --- .../logic/main/converter/destination.py | 8 ---- .../logic/main/converter/logic.py | 1 - .../logic/main/converter/messages.py | 24 ++++------- .../logic/main/converter/run.py | 8 +--- .../services/conversion/result.py | 7 ++-- .../services/conversion/service.py | 14 ++++--- .../reconstructions/converter/converter.py | 12 +++--- .../logic/main/converter/test_destination.py | 28 ++----------- .../logic/main/converter/test_logic.py | 42 +++++++++++++++++-- .../logic/main/converter/test_messages.py | 26 ++++++------ .../logic/main/converter/test_run.py | 35 ++++++++++------ .../services/test_conversion.py | 35 ++++++++++++---- .../converter/test_converter.py | 30 +++++++++++++ 13 files changed, 165 insertions(+), 105 deletions(-) diff --git a/src/sampletones_application/logic/main/converter/destination.py b/src/sampletones_application/logic/main/converter/destination.py index 2184e6b17..47ff081f6 100644 --- a/src/sampletones_application/logic/main/converter/destination.py +++ b/src/sampletones_application/logic/main/converter/destination.py @@ -24,14 +24,6 @@ def unset(cls) -> Self: """The destination a converter opens with, before a reader has picked anything.""" return cls(input_path=None, output_path=None, is_file=True) - @property - def reconstruction_name(self) -> str: - """The document a single job writes, which is what a run of one is making.""" - if self.output_path is not None: - return self.output_path.stem - - return self.input_path.stem if self.input_path is not None else "" - def aimed_at(self, config: Config, plan: Optional[ConversionPlan]) -> Self: """The destination the plan a run amounts to names. diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index f1b60b904..e33dab690 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -408,7 +408,6 @@ def start_conversion(self, confirmed: bool = False) -> None: ConversionRequest( config=config, plan=plan, - reconstruction_name=self._state.destination.reconstruction_name, library_key=library_key, library_state=self.query( self.library_state, diff --git a/src/sampletones_application/logic/main/converter/messages.py b/src/sampletones_application/logic/main/converter/messages.py index a01c3d096..04565dd04 100644 --- a/src/sampletones_application/logic/main/converter/messages.py +++ b/src/sampletones_application/logic/main/converter/messages.py @@ -36,19 +36,16 @@ def __init__(self, language_manager: LanguageManager) -> None: ReconstructionStage.GATHERING: language_manager["main.converter.message.stage_gathering"], } - def progress_text( - self, - progress: ServiceProgress[ConversionItem], - reconstruction_name: str, - ) -> str: + def progress_text(self, progress: ServiceProgress[ConversionItem]) -> str: """What the run is doing, how far it has come, and how long it has left. - A run writing one reconstruction names the document it is making and the stage that - document is at, which is the whole of what a reader watching one reconstruction has. A + A run writing one reconstruction names the file its job writes and the stage that + reconstruction is at, which is the whole of what a reader watching one reconstruction has. + That holds for a mix, a single recording and a rerun left with one recording to write. A batch writes many at once, so a count of the ones written says where it stands. """ return ( - self._run_text(progress, reconstruction_name) + self._run_text(progress) + self._stage_text(progress) + time_estimation(self._language_manager, progress.eta_seconds) ) @@ -90,14 +87,11 @@ def action_label( return self._language_manager["main.converter.label.convert_button"] - def _run_text( - self, - progress: ServiceProgress[ConversionItem], - reconstruction_name: str, - ) -> str: - if progress.is_single: + def _run_text(self, progress: ServiceProgress[ConversionItem]) -> str: + item = progress.current_item + if progress.is_single and item is not None: return self._language_manager["main.converter.template.single_progress_template"].format( - reconstruction_name + item.output_path.stem ) return self._language_manager["main.converter.template.progress_template"].format( diff --git a/src/sampletones_application/logic/main/converter/run.py b/src/sampletones_application/logic/main/converter/run.py index bb7089926..48e7da89d 100644 --- a/src/sampletones_application/logic/main/converter/run.py +++ b/src/sampletones_application/logic/main/converter/run.py @@ -51,7 +51,6 @@ class ConversionRequest: config: Config plan: ConversionPlan - reconstruction_name: str library_key: InstructionLibraryKey library_state: LibraryState @@ -98,8 +97,7 @@ class ConversionRun(CallbackMixin): The run owns the phase, the service driving it and the taskbar progress that follows it, and reports where it stands after every step it takes. What it converts is settled before it - starts: a run takes a plan and the name of the document it is writing, and answers only for - what happens to them. + starts: a run takes a plan and answers only for what happens to it. """ def __init__( @@ -113,7 +111,6 @@ def __init__( self._system_progress = SystemProgress() self._phase: ConversionPhase = ConversionPhase.IDLE self._written: Tuple[Path, ...] = () - self._reconstruction_name: str = "" self._request: Optional[ConversionRequest] = None self._service.subscribe(self._on_service_result) @@ -156,7 +153,6 @@ def wait(self, request: ConversionRequest) -> None: def begin(self, request: ConversionRequest) -> None: """Hands the request to the service, which is where the conversion itself starts.""" - self._reconstruction_name = request.reconstruction_name self._system_progress.initialize() self._service.start(request.config, request.plan) @@ -216,7 +212,7 @@ def _handle_progress_result(self, progress: ServiceProgress[ConversionItem]) -> SYSTEM_PROGRESS_STEPS, ) self._report( - self._messages.progress_text(progress, self._reconstruction_name), + self._messages.progress_text(progress), progress.fraction, input_path=self._item_path(progress), ) diff --git a/src/sampletones_application/services/conversion/result.py b/src/sampletones_application/services/conversion/result.py index 41caa70e4..f53dd89a9 100644 --- a/src/sampletones_application/services/conversion/result.py +++ b/src/sampletones_application/services/conversion/result.py @@ -32,12 +32,13 @@ class ReconstructionStep: class ConversionItem: """The reconstruction a conversion is building, and what it is doing to build it. - A run knows which recording it is reading from the moment it starts, and hears what that - reconstruction is doing once the reconstruction has something to say, so the step arrives on - an item that already names its source. + A run knows which recording its job reads and which file it writes from the moment it starts, + and hears what that reconstruction is doing once the reconstruction has something to say, so + the step arrives on an item that already names its source and its output. """ source: Path + output_path: Path step: Optional[ReconstructionStep] = None diff --git a/src/sampletones_application/services/conversion/service.py b/src/sampletones_application/services/conversion/service.py index 4a2a64ad4..811467a9f 100644 --- a/src/sampletones_application/services/conversion/service.py +++ b/src/sampletones_application/services/conversion/service.py @@ -22,7 +22,6 @@ from sampletones_core.reconstructions.converter import ConversionPlan, ReconstructionConverter from sampletones_core.reconstructions.stage import ReconstructionStage from sampletones_shared.logger import logger -from sampletones_shared.utils.system.paths import to_path class ConversionService(ServiceBase[ConversionResult]): @@ -122,20 +121,23 @@ def _estimate(self, covered: float) -> Optional[float]: return self._eta_estimator.update(covered) - @staticmethod def _item( + self, task_progress: TaskProgress, step: Optional[ReconstructionStep], ) -> Optional[ConversionItem]: - """The reconstruction the run names itself by, where it has a recording to name. + """The reconstruction the run names itself by, where it has a job to name. A run works on as many reconstructions as it has workers and names the one it has been at - longest, so a reader watching a batch sees the run move through its recordings. + longest, so a reader watching a batch sees the run move through its recordings. The item + names that job's recording and the file it writes. """ - if task_progress.current_item is None: + converter = self._converter + job = converter.job_under_way(task_progress.completed) if converter is not None else None + if job is None: return None - return ConversionItem(source=to_path(task_progress.current_item), step=step) + return ConversionItem(source=job.sources[0], output_path=job.output_path, step=step) @staticmethod def _step(task_progress: TaskProgress) -> Optional[ReconstructionStep]: diff --git a/src/sampletones_core/reconstructions/converter/converter.py b/src/sampletones_core/reconstructions/converter/converter.py index 6f91bcbdc..dc4bc630d 100644 --- a/src/sampletones_core/reconstructions/converter/converter.py +++ b/src/sampletones_core/reconstructions/converter/converter.py @@ -61,18 +61,18 @@ def _process_results(self, results: List[Path]) -> Tuple[Path, ...]: return tuple(output_path for output_path in results if output_path.exists()) def _notify_progress(self) -> None: - self.current_item = self._running_source() + job = self.job_under_way(self.completed_tasks) + self.current_item = str(job.sources[0]) if job is not None else None super()._notify_progress() - def _running_source(self) -> Optional[str]: - """The recording the job the run has been working on longest is reading. + def job_under_way(self, completed: int) -> Optional[ConversionJob]: + """The job the run names itself by once ``completed`` jobs are counted. Jobs are answered in the order they were handed out, so the first one the run has yet to count is the earliest still under way — and once every job is counted, the last one is - what the run finished on. + what the run finished on. A run that has yet to read its plan holds no job. """ if not self.jobs: return None - index = min(self.completed_tasks, len(self.jobs) - 1) - return str(self.jobs[index].sources[0]) + return self.jobs[min(completed, len(self.jobs) - 1)] diff --git a/tests/unit/sampletones_application/logic/main/converter/test_destination.py b/tests/unit/sampletones_application/logic/main/converter/test_destination.py index 00662b69e..430b33c28 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_destination.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_destination.py @@ -13,35 +13,13 @@ _TRIANGLE: Final[StemsConfig] = StemsConfig.single_entry(StemSettings.covering([ChannelName.TRIANGLE])) -class TestTheDocumentARunIsMaking(BaseTestSuite): - """A run of one names itself after the reconstruction it writes.""" - - def test_the_output_names_the_reconstruction(self) -> None: - destination = Destination( - input_path=Path("/audio/kick.wav"), - output_path=Path("/reconstructions/track.stn"), - is_file=True, - ) - - assert destination.reconstruction_name == "track" - - def test_a_run_yet_to_resolve_its_output_names_the_recording_picked(self) -> None: - destination = Destination(input_path=Path("/audio/kick.wav"), output_path=None, is_file=True) - - assert destination.reconstruction_name == "kick" - - def test_a_converter_aimed_at_nothing_names_nothing(self) -> None: - assert Destination.unset().reconstruction_name == "" +class TestWhereARunWrites(BaseTestSuite): + """The destination names what the run's plan names, and a run with no plan names nothing new.""" def test_a_completed_run_names_what_it_wrote(self) -> None: written = Path("/reconstructions/mixed.stn") - destination = Destination.unset().writing_to(written) - - assert (destination.output_path, destination.reconstruction_name) == (written, "mixed") - -class TestWhereARunWrites(BaseTestSuite): - """The destination names what the run's plan names, and a run with no plan names nothing new.""" + assert Destination.unset().writing_to(written).output_path == written def test_a_run_with_nobody_taking_part_stands_where_it_was(self) -> None: destination = Destination.unset().writing_to(Path("/reconstructions/earlier.stn")) diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index e7330c294..5c93e3d53 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -12,8 +12,8 @@ from sampletones_application.logic.instruction.readiness import LibraryReadiness from sampletones_application.logic.main.converter.logic import ConverterLogic from sampletones_application.logic.main.converter.run import ConversionSuccess -from sampletones_application.services.conversion.result import ConversionResult -from sampletones_application.services.result import ServiceError, ServiceSuccess +from sampletones_application.services.conversion.result import ConversionItem, ConversionResult +from sampletones_application.services.result import ServiceError, ServiceProgress, ServiceSuccess from sampletones_application.view_model.main.converter import ( ACTIVE_PHASES, ConversionPhase, @@ -24,7 +24,7 @@ from sampletones_core.constants.algorithm import UNIT_DRIVE from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.library import LibraryState -from sampletones_core.reconstructions.converter import GroupConversion +from sampletones_core.reconstructions.converter import ConversionJob, GroupConversion from sampletones_core.reconstructions.converter.paths import get_audio_files from tests.suite.base import BaseTestSuite from tests.suite.language import FakeLanguageManager @@ -128,6 +128,11 @@ def _card_channels(converter_logic: ConverterLogic) -> FrozenSet[ChannelName]: ) +def _item_of(job: ConversionJob) -> ConversionItem: + """The item the conversion service reports for ``job`` while the run works on it.""" + return ConversionItem(source=job.sources[0], output_path=job.output_path) + + def _started_plan(converter_logic: ConverterLogic, service: MagicMock) -> GroupConversion: """The plan the converter hands the service once the library it waits for is ready.""" converter_logic.library_readiness = lambda directory, key: LibraryReadiness.READY @@ -513,6 +518,37 @@ def test_a_batch_loads_the_folder_and_one_file_loads_itself( converter_logic.on_load_directory.assert_called_once_with() +class TestTheLineARunOfOneShows(BaseTestSuite): + """A run with one job names the reconstruction that job writes, whatever the setup is named after.""" + + def test_a_folder_left_with_one_job_names_its_recording( + self, + converter_logic: ConverterLogic, + service: MagicMock, + tmp_path: Path, + ) -> None: + """A rerun over a folder writes only the recordings still to be written, so a folder left with + one names that recording, and the folder its destination names stays out of the line.""" + loops = tmp_path / "loops" + loops.mkdir() + for name in ("bass.wav", "lead.wav"): + (loops / name).touch() + converter_logic.gather_folder(loops, get_audio_files(loops, sort=True)) + plan = _started_plan(converter_logic, service) + config: Config = service.start.call_args.args[0] + written, still_to_write = plan.jobs(config) + written.output_path.parent.mkdir(parents=True) + written.output_path.touch() + (job,) = plan.jobs(config) + + _reports(service, ServiceProgress(completed=0, total=1, current_item=_item_of(job))) + + assert (job, _view(converter_logic).status_text) == ( + still_to_write, + TEXTS["main.converter.template.single_progress_template"].format(job.output_path.stem), + ) + + class TestFailureReturnsToIdle(BaseTestSuite): """With no Close button, a failure reports through ``on_error`` and schedules its own return to idle so the panel never strands on the failed phase.""" diff --git a/tests/unit/sampletones_application/logic/main/converter/test_messages.py b/tests/unit/sampletones_application/logic/main/converter/test_messages.py index 76ba824c2..1c998d225 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_messages.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_messages.py @@ -33,35 +33,37 @@ def _progress( ) -def _item(stage: ReconstructionStage, completed: int) -> ConversionItem: +def _item(output_name: str, step: Optional[ReconstructionStep] = None) -> ConversionItem: + """The item a run reports while it writes the reconstruction ``output_name`` from a kick drum.""" return ConversionItem( source=Path("/audio/kick.wav"), - step=ReconstructionStep(stage=stage, completed=completed, total=FRAMES), + output_path=Path(f"/reconstructions/{output_name}.stn"), + step=step, ) +def _step(stage: ReconstructionStage, completed: int) -> ReconstructionStep: + return ReconstructionStep(stage=stage, completed=completed, total=FRAMES) + + class TestProgressText(BaseTestSuite): - """A batch counts the files it has written; a run of one names the reconstruction it is making. + """A batch counts the files it has written; a run of one names the reconstruction its job writes. The reading states which of the two the run is, and carries a stage where the run has one reconstruction to name a stage for, so the words follow the unit the run reports in. """ def test_a_batch_counts_its_files(self) -> None: - assert messages().progress_text(_progress(2, 5), "track") == "Progress: 2/5 files" + assert messages().progress_text(_progress(2, 5, item=_item("kick"))) == "Progress: 2/5 files" def test_a_single_job_names_the_reconstruction_it_writes(self) -> None: - assert messages().progress_text(_progress(0, 1), "track") == "Reconstructing track..." + """The job's file names the line, which differs from the recording it reads.""" + assert messages().progress_text(_progress(0, 1, item=_item("track"))) == "Reconstructing track..." def test_the_status_names_the_stage_and_its_counts(self) -> None: - progress = _progress(0, 1, item=_item(ReconstructionStage.MATCHING, 412), partial=0.35) - - assert messages().progress_text(progress, "kick") == "Reconstructing kick... - matching 412/1100" - - def test_a_run_yet_to_say_anything_still_names_its_recording(self) -> None: - progress = _progress(0, 1, item=ConversionItem(source=Path("/audio/kick.wav"))) + progress = _progress(0, 1, item=_item("kick", _step(ReconstructionStage.MATCHING, 412)), partial=0.35) - assert messages().progress_text(progress, "kick") == "Reconstructing kick..." + assert messages().progress_text(progress) == "Reconstructing kick... - matching 412/1100" class TestActionLabel(BaseTestSuite): diff --git a/tests/unit/sampletones_application/logic/main/converter/test_run.py b/tests/unit/sampletones_application/logic/main/converter/test_run.py index 31f3f3d06..69d2146c9 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_run.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_run.py @@ -29,11 +29,10 @@ WRITTEN: Tuple[Path, ...] = (Path("/reconstructions/kick.stn"),) -def _request(reconstruction_name: str, library_state: LibraryState) -> ConversionRequest: +def _request(library_state: LibraryState) -> ConversionRequest: return ConversionRequest( config=Config(), plan=MagicMock(), - reconstruction_name=reconstruction_name, library_key=MagicMock(), library_state=library_state, ) @@ -66,8 +65,8 @@ def reports_from_service(self, result: ConversionResult) -> None: """Hands the run a result the conversion service would report to it.""" self._handler(result) - def begin(self, reconstruction_name: str = "kick") -> None: - request = _request(reconstruction_name, LibraryState.CURRENT) + def begin(self) -> None: + request = _request(LibraryState.CURRENT) self.run.wait(request) self.run.begin(request) @@ -84,7 +83,7 @@ def test_a_fresh_run_is_idle(self, driver: Driver) -> None: assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.IDLE, False) def test_a_request_waits_for_the_library_it_converts_against(self, driver: Driver) -> None: - driver.run.wait(_request("kick", LibraryState.MISSING)) + driver.run.wait(_request(LibraryState.MISSING)) assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.WAITING, True) @@ -127,14 +126,20 @@ def test_a_settled_run_holds_nothing( class TestWhatARunReports(BaseTestSuite): def test_a_request_says_it_is_waiting(self, driver: Driver) -> None: - driver.run.wait(_request("kick", LibraryState.MISSING)) + driver.run.wait(_request(LibraryState.MISSING)) assert driver.reports[-1].status_text == "main.converter.message.status_waiting" - def test_progress_names_the_document_being_written(self, driver: Driver) -> None: - driver.begin(reconstruction_name="track") + def test_progress_names_the_document_its_job_writes(self, driver: Driver) -> None: + driver.begin() - driver.reports_from_service(ServiceProgress(completed=0, total=1)) + driver.reports_from_service( + ServiceProgress( + completed=0, + total=1, + current_item=ConversionItem(source=Path("/audio/kick.wav"), output_path=Path("/out/track.stn")), + ) + ) assert driver.reports[-1].status_text == "Reconstructing track..." @@ -142,7 +147,11 @@ def test_progress_names_the_recording_under_way(self, driver: Driver) -> None: driver.begin() driver.reports_from_service( - ServiceProgress(completed=0, total=2, current_item=ConversionItem(source=Path("/audio/snare.wav"))) + ServiceProgress( + completed=0, + total=2, + current_item=ConversionItem(source=Path("/audio/snare.wav"), output_path=Path("/out/snare.stn")), + ) ) assert driver.reports[-1].input_path == Path("/audio/snare.wav") @@ -164,7 +173,7 @@ def test_a_canceled_run_keeps_reporting_the_canceling_line(self, driver: Driver) assert driver.run.phase == ConversionPhase.CANCELING def test_library_progress_moves_the_bar_while_waiting(self, driver: Driver) -> None: - driver.run.wait(_request("kick", LibraryState.MISSING)) + driver.run.wait(_request(LibraryState.MISSING)) driver.reports_from_service(ServiceStarted(total=1)) driver.reports_from_service(_library_progress(completed=3, total=4)) @@ -185,7 +194,7 @@ def test_library_progress_names_what_the_library_needs( library_state: LibraryState, status_text: str, ) -> None: - driver.run.wait(_request("kick", library_state)) + driver.run.wait(_request(library_state)) driver.reports_from_service(_library_progress(completed=1, total=4)) @@ -229,7 +238,7 @@ def test_a_cancellation_says_so(self, driver: Driver) -> None: driver.run.on_canceled.assert_called_once_with() def test_a_request_given_up_before_the_service_took_it_cancels_all_the_same(self, driver: Driver) -> None: - driver.run.wait(_request("kick", LibraryState.MISSING)) + driver.run.wait(_request(LibraryState.MISSING)) driver.run.abandon() diff --git a/tests/unit/sampletones_application/services/test_conversion.py b/tests/unit/sampletones_application/services/test_conversion.py index 992dee2f1..61cc96d3e 100644 --- a/tests/unit/sampletones_application/services/test_conversion.py +++ b/tests/unit/sampletones_application/services/test_conversion.py @@ -15,8 +15,12 @@ ServiceStarted, ServiceSuccess, ) +from sampletones_core.constants.enums import ChannelName from sampletones_core.parallelization import TaskProgress, TaskStatus from sampletones_core.parallelization.task import TaskStep +from sampletones_core.reconstructions.converter import ConversionJob +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_core.reconstructions.stage import ReconstructionStage from tests.suite.base import BaseTestSuite @@ -25,6 +29,12 @@ Reading: TypeAlias = Callable[[TaskProgress], ServiceProgress[ConversionItem]] SOURCE: Final[str] = "/audio/kick.wav" +OUTPUT: Final[str] = "/reconstructions/kick.stn" +JOB: Final[ConversionJob] = ConversionJob( + sources=(Path(SOURCE),), + stems=StemsConfig.single_entry(StemSettings.covering([ChannelName.PULSE1])), + output_path=Path(OUTPUT), +) FRAMES: Final[int] = 1100 ONE_RECONSTRUCTION: Final[int] = 1 SEVERAL_RECONSTRUCTIONS: Final[int] = 5 @@ -80,6 +90,7 @@ def mock_converter_class() -> Iterator[MockConverterClass]: instance.is_running.return_value = False instance.status = TaskStatus.COMPLETED instance.total_tasks = 5 + instance.job_under_way.return_value = JOB captured: Dict[str, Callable[..., Any]] = {} instance.set_callbacks.side_effect = lambda **kwargs: captured.update(kwargs) @@ -167,7 +178,7 @@ def test_on_progress_running_emits_service_progress( callbacks["on_start"]() results.clear() - progress = TaskProgress(total=5, completed=2, current_item="/some/file.wav") + progress = TaskProgress(total=5, completed=2, current_item=SOURCE) callbacks["on_progress"](TaskStatus.RUNNING, progress) assert len(results) == 1 @@ -175,8 +186,19 @@ def test_on_progress_running_emits_service_progress( assert isinstance(result, ServiceProgress) assert result.completed == 2 assert result.total == 5 - assert result.current_item is not None - assert result.current_item.source == Path("/some/file.wav") + assert result.current_item == ConversionItem(source=Path(SOURCE), output_path=Path(OUTPUT)) + + def test_the_item_names_the_job_the_progress_counts_up_to( + self, + service: Service, + ) -> None: + """The item is read from the job the converter is at once the progress's jobs are counted.""" + _, converter, callbacks, _ = service + callbacks["on_start"]() + + callbacks["on_progress"](TaskStatus.RUNNING, TaskProgress(total=5, completed=3)) + + converter.job_under_way.assert_called_with(3) def test_on_progress_canceling_emits_service_progress( self, @@ -222,7 +244,8 @@ def test_on_progress_current_item_none_when_absent( self, service: Service, ) -> None: - _, _, callbacks, results = service + _, converter, callbacks, results = service + converter.job_under_way.return_value = None callbacks["on_start"]() results.clear() @@ -397,9 +420,7 @@ def test_a_batch_names_the_recording_it_is_at_and_no_stage(self, service: Servic reading = read(_account(SEVERAL_RECONSTRUCTIONS, TWO_WRITTEN, _under_way(EARLY, HALFWAY, NEARLY_DONE))) - assert reading.current_item is not None - assert reading.current_item.source == Path(SOURCE) - assert reading.current_item.step is None + assert reading.current_item == ConversionItem(source=Path(SOURCE), output_path=Path(OUTPUT)) def test_a_batch_reads_as_the_reconstructions_it_has_written(self, service: Service) -> None: read = _run_of(service, SEVERAL_RECONSTRUCTIONS) diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py index e0a140511..2932bf149 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py @@ -58,6 +58,36 @@ def test_already_running_does_not_spawn_thread( assert converter.monitor_thread is None +class TestTheJobUnderWay: + """A run names itself by the earliest job it has yet to count, and by the last once every job is + counted.""" + + def test_the_count_walks_the_jobs_and_rests_on_the_last( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + for name in ("a.wav", "b.wav"): + (tmp_path / name).touch() + converter = ReconstructionConverter(config, DirectoryConversion(directory=tmp_path, stems=stems)) + with patch(_RECONSTRUCTOR_PATCH): + converter._create_tasks() + first, second = converter.jobs + + assert [converter.job_under_way(completed) for completed in range(3)] == [first, second, second] + + def test_a_run_yet_to_read_its_plan_names_no_job( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + converter = ReconstructionConverter(config, _group(tmp_path / "song.wav", stems)) + + assert converter.job_under_way(0) is None + + class TestReconstructionConverterCreateTasks: def test_a_group_plan_returns_a_single_task( self, From 06c495e64c157e3840fcad3fc85bb598a2f37d53 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 15:58:07 +0200 Subject: [PATCH 53/84] Fixed: a raise after an answer or a wait stranding a gesture's conversation, a raising save and a raising hand-off --- docs/development/application/dialogs.md | 6 +- .../utils/callbacks/gates.py | 85 +++++++++---- .../gui/dialogs/windows/save_confirmation.py | 17 ++- .../utils/gui/modal_queue.py | 16 ++- .../utils/callbacks/test_gates.py | 118 +++++++++++++++++- .../dialogs/windows/test_save_confirmation.py | 33 ++++- .../utils/gui/test_modal_queue.py | 34 +++++ 7 files changed, 279 insertions(+), 30 deletions(-) diff --git a/docs/development/application/dialogs.md b/docs/development/application/dialogs.md index 14180a064..698fea7de 100644 --- a/docs/development/application/dialogs.md +++ b/docs/development/application/dialogs.md @@ -66,7 +66,8 @@ opens at once. A dialog that closes on its answer leaves the screen first, and the answer runs a frame later as a hand-off of its conversation. Whatever the answer raises, such as a question of its own or an error, opens ahead of the line. Leaving also releases the dialog's keyboard claim, so a prompt the answer raises holds the -keyboard alone. `GUIWindow._leave_then` is that step. +keyboard alone. `GUIWindow._leave_then` is that step. An answer that fails with an error leaves the hand-offs +after it, and the line, to go on a frame later. What the answer needs, such as a ticked box or the fields of a form, is read before the dialog leaves. Only the first answer runs: a second click reaches a dialog that has already gone. @@ -87,7 +88,8 @@ stands aside, so nothing waiting in line opens between it and the prompt it rais A gesture that asks before it replaces a document, closes one or leaves the application holds one conversation at a time. The conversation is the chain of questions the gesture passes, and every way out of a question reaches whoever asked it. An answer that goes on lets the request through. Cancel, Escape, the -title bar's close and a save that failed turn it away. A request therefore always ends in one of the two. +title bar's close and a save that failed turn it away. So does an error raised anywhere along the chain, +before a question or after its answer. A request therefore always ends in one of the two. While a conversation stands, the same gesture asked for again asks nothing, so two closes before the first is answered ask once. Once the conversation has ended, the gesture asks again. The span covers the wait for the diff --git a/src/sampletones_application/utils/callbacks/gates.py b/src/sampletones_application/utils/callbacks/gates.py index ccf5f5257..5931862f3 100644 --- a/src/sampletones_application/utils/callbacks/gates.py +++ b/src/sampletones_application/utils/callbacks/gates.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from dataclasses import dataclass from functools import partial from typing import Callable, Generic, Optional, ParamSpec, Sequence, TypeVar @@ -72,6 +73,20 @@ def run() -> None: return call +@dataclass(eq=False) +class Ticket: + """One take-off of a gesture, and the request its end lets through. + + Each take-off gets a ticket of its own, so a continuation handed out during one conversation + reaches that conversation alone. + + Attributes: + request: The gesture's arrival the end of the flight lets through. + """ + + request: VoidCallback + + class SingleFlight(ABC, Generic[GestureParameters]): """A gesture that holds one conversation at a time, and lets one request through at its end. @@ -79,18 +94,20 @@ class SingleFlight(ABC, Generic[GestureParameters]): asked for until the gates let it through or turn it away, and a gesture asked for after that asks again. A gesture asked for while it is in flight asks nothing, and the kind of flight decides which request its end lets through. A conversation that turns the gesture away drops the request it - holds. A gate that raises ends the flight the same way, so one failure leaves the gesture to be - asked for again. + holds. A gate, or a continuation a gate runs after an answer or a wait, that raises ends the + flight the same way, so one failure leaves the gesture to be asked for again. Each take-off has a + ticket, and a continuation acts on the flight of its own ticket alone, so an answer reaching a + conversation that has ended leaves the newer one as it stands. """ def __init__(self, arrive: Callable[GestureParameters, GestureResult]) -> None: self._arrive = arrive - self._standing: Optional[VoidCallback] = None + self._ticket: Optional[Ticket] = None @property def in_flight(self) -> bool: """Whether a conversation of this gesture is under way, from the moment it is asked for to its end.""" - return self._standing is not None + return self._ticket is not None @abstractmethod def __call__(self, *args: GestureParameters.args, **kwargs: GestureParameters.kwargs) -> None: @@ -106,28 +123,54 @@ def arrival() -> None: def _take_off(self, gates: Sequence[Gate], request: VoidCallback) -> None: """Asks ``gates`` with ``request`` standing, and holds the flight until they let it through or turn it away.""" - self._standing = request - asked = False - try: - pass_gates(gates, self._land, self._turn_away) - asked = True - finally: - if not asked: - self._turn_away() + ticket = Ticket(request=request) + self._ticket = ticket + conversation = partial( + pass_gates, + [self._guarded_gate(ticket, gate) for gate in gates], + partial(self._land, ticket), + partial(self._turn_away, ticket), + ) + self._guarded(ticket, conversation)() def _redirect(self, request: VoidCallback) -> None: """Lets ``request`` through at the end of the flight under way, in the place of the one standing.""" - self._standing = request + assert self._ticket is not None, "A request was redirected with no flight under way" + self._ticket.request = request + + def _guarded_gate(self, ticket: Ticket, gate: Gate) -> Gate: + """``gate`` handed continuations that end the flight of ``ticket`` when they raise.""" + + def guarded(proceed: VoidCallback, decline: VoidCallback) -> None: + gate(self._guarded(ticket, proceed), self._guarded(ticket, decline)) + + return guarded + + def _guarded(self, ticket: Ticket, continuation: VoidCallback) -> VoidCallback: + """``continuation`` turning the flight of ``ticket`` away when it raises, with the error going on up.""" + + def run() -> None: + went_on = False + try: + continuation() + went_on = True + finally: + if not went_on: + self._turn_away(ticket) + + return run + + def _land(self, ticket: Ticket) -> None: + """Ends the flight of ``ticket``, and then lets its request through, so its arrival may ask anew.""" + if self._ticket is not ticket: + return - def _land(self) -> None: - """Ends the flight, and then lets the request standing through, so its arrival may ask anew.""" - request = self._standing - assert request is not None, "A conversation let its gesture through after its flight had ended" - self._standing = None - request() + self._ticket = None + ticket.request() - def _turn_away(self) -> None: - self._standing = None + def _turn_away(self, ticket: Ticket) -> None: + if self._ticket is ticket: + self._ticket = None class LatestRequestFlight(SingleFlight[GestureParameters]): diff --git a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py index b54769a7d..7321bfceb 100644 --- a/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py +++ b/src/sampletones_application/utils/gui/dialogs/windows/save_confirmation.py @@ -138,9 +138,10 @@ def _save_and_go_on(self) -> None: A written document goes on to what the prompt was guarding. A save the reader called off puts the same question again. A failed save showed its error, which stands alone, and the - request the prompt guarded goes back the way Cancel takes it. + request the prompt guarded goes back the way Cancel takes it. A save that raises turns the + request back the same way, and its error goes on up. """ - match self._on_save(): + match self._saved(): case SaveOutcome.WRITTEN: self._on_confirm() case SaveOutcome.CALLED_OFF: @@ -154,3 +155,15 @@ def _save_and_go_on(self) -> None: ) case SaveOutcome.FAILED: self._on_cancel() + + def _saved(self) -> SaveOutcome: + """What the save came to, with the request the prompt guarded turned back where the save raises.""" + reported = False + try: + outcome = self._on_save() + reported = True + finally: + if not reported: + self._on_cancel() + + return outcome diff --git a/src/sampletones_application/utils/gui/modal_queue.py b/src/sampletones_application/utils/gui/modal_queue.py index f503d6f13..0b8135266 100644 --- a/src/sampletones_application/utils/gui/modal_queue.py +++ b/src/sampletones_application/utils/gui/modal_queue.py @@ -204,13 +204,23 @@ def _turn(cls) -> None: @classmethod def _run_hand_offs(cls) -> None: - hand_offs, cls._hand_offs = cls._hand_offs, [] + """Runs the hand-offs due, in the order they were handed. + + A hand-off that raises keeps the ones after it for a coming frame, and the line goes on from + there, so one failing answer leaves the screen to the rest of the conversation and the line. + """ + hand_offs, cls._hand_offs = deque(cls._hand_offs), [] cls._handing_off = True + ran = False try: - for continuation in hand_offs: - continuation() + while hand_offs: + hand_offs.popleft()() + ran = True finally: cls._handing_off = False + if not ran: + cls._hand_offs = list(hand_offs) + cls._hand_offs + cls._take_a_turn() @classmethod def _bring_back(cls) -> bool: diff --git a/tests/unit/sampletones_application/utils/callbacks/test_gates.py b/tests/unit/sampletones_application/utils/callbacks/test_gates.py index dd24eaa90..44ff8f014 100644 --- a/tests/unit/sampletones_application/utils/callbacks/test_gates.py +++ b/tests/unit/sampletones_application/utils/callbacks/test_gates.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, Final, List, Optional, Sequence +from typing import Callable, Final, List, Optional, Sequence, Tuple import pytest @@ -180,6 +180,16 @@ def __call__(self, proceed: VoidCallback, _decline: VoidCallback) -> None: proceed() +class Answers: + """A gate that asks each time it is reached and keeps every pair of answers it was handed, stale ones included.""" + + def __init__(self) -> None: + self.handed: List[Tuple[VoidCallback, VoidCallback]] = [] + + def __call__(self, proceed: VoidCallback, decline: VoidCallback) -> None: + self.handed.append((proceed, decline)) + + def latest_request(gates: Sequence[Gate], arrive: VoidCallback) -> SingleFlight[[]]: return LatestRequestFlight(gates, arrive) @@ -303,6 +313,112 @@ def broken(_proceed: VoidCallback, _decline: VoidCallback) -> None: flight() assert reached == ["broken", "broken"] + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_raise_once_the_wait_lets_go_ends_the_flight(self, test_case: TestCase, reached: List[str]) -> None: + """The edits landing run the rest of the conversation, and a failure there leaves the gesture to ask again.""" + wait = HeldWait() + flight = test_case.build((waiting(wait), RaisingOnce(reached)), lambda: reached.append(ARRIVED)) + flight() + + with pytest.raises(RuntimeError): + wait.release() + + assert not flight.in_flight + flight() + assert flight.in_flight + wait.release() + assert reached == ["broken", "broken", ARRIVED] + assert not flight.in_flight + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_raise_once_the_question_is_answered_ends_the_flight( + self, + test_case: TestCase, + guard: Guard, + reached: List[str], + ) -> None: + """An answer runs the rest of the conversation, and a failure there leaves the gesture to ask again.""" + flight = test_case.build((guard, RaisingOnce(reached)), lambda: reached.append(ARRIVED)) + flight() + + with pytest.raises(RuntimeError): + guard.answer() + + assert not flight.in_flight + flight() + assert guard.is_asking + guard.answer() + assert reached == ["question", "broken", "question", "broken", ARRIVED] + assert not flight.in_flight + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_stale_answer_going_on_leaves_the_newer_flight_standing( + self, + test_case: TestCase, + reached: List[str], + ) -> None: + """An answer handed to a conversation that has ended reaches nothing the newer one holds.""" + answers = Answers() + flight = test_case.build((answers,), lambda: reached.append(ARRIVED)) + flight() + stale_proceed, stale_decline = answers.handed[0] + stale_decline() + flight() + + stale_proceed() + + assert flight.in_flight + assert reached == [] + newer_proceed, _ = answers.handed[1] + newer_proceed() + assert reached == [ARRIVED] + assert not flight.in_flight + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_stale_answer_turning_away_leaves_the_newer_flight_standing( + self, + test_case: TestCase, + reached: List[str], + ) -> None: + answers = Answers() + flight = test_case.build((answers,), lambda: reached.append(ARRIVED)) + flight() + stale_proceed, stale_decline = answers.handed[0] + stale_proceed() + flight() + + stale_decline() + + assert flight.in_flight + newer_proceed, _ = answers.handed[1] + newer_proceed() + assert reached == [ARRIVED, ARRIVED] + assert not flight.in_flight + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_an_arrival_that_asks_again_and_raises_keeps_the_newer_flight( + self, + test_case: TestCase, + reached: List[str], + ) -> None: + """A gesture whose arrival asks for it again and then fails leaves the question it asked standing.""" + guard = Guard("question", reached, unfinished=False) + + def arrive() -> None: + reached.append(ARRIVED) + guard.unfinished = True + flight() + raise RuntimeError("the arrival failed after asking again") + + flight = test_case.build((guard,), arrive) + + with pytest.raises(RuntimeError): + flight() + + assert flight.in_flight + assert guard.is_asking + assert reached == ["question", ARRIVED, "question"] + class TestLatestRequestFlight: """A conversation that is the same whatever the gesture carries lets the latest request through. diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py index b6efecf2f..d776c5a2e 100644 --- a/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py +++ b/tests/unit/sampletones_application/utils/gui/dialogs/windows/test_save_confirmation.py @@ -1,4 +1,4 @@ -from typing import Final, Iterator, List +from typing import Callable, Final, Iterator, List from unittest.mock import patch import dearpygui.dearpygui as dpg @@ -65,6 +65,16 @@ def save() -> SaveOutcome: answers.append(SAVED) return save_outcome + build(window, save=save, answers=answers) + + +def build( + window: GUISaveConfirmationWindow, + *, + save: Callable[[], SaveOutcome], + answers: List[str], +) -> None: + """Builds the prompt over ``save``, recording each way forward and back in ``answers``.""" window.prepare( MESSAGE, "Title", @@ -196,6 +206,27 @@ def test_a_failed_save_leaves_the_error_alone_on_screen_and_answers_cancel( assert not dpg.does_item_exist(WINDOW_TAG) assert held_frames.pending == 0 + def test_a_save_that_raises_answers_cancel_and_lets_the_error_through( + self, + window: GUISaveConfirmationWindow, + held_frames: Frames, + ) -> None: + """A save failing with an error it names no outcome for still ends the request the prompt guarded.""" + answers: List[str] = [] + + def save() -> SaveOutcome: + answers.append(SAVED) + raise RuntimeError("the save broke") + + build(window, save=save, answers=answers) + press(SUF_BUTTON_SAVE) + + with pytest.raises(RuntimeError): + held_frames.render() + + assert answers == [SAVED, CANCELED] + assert not dpg.does_item_exist(WINDOW_TAG) + class TestTheOtherAnswers: def test_the_middle_button_goes_on_once_the_prompt_has_left( 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 61ecd9d8e..255d9b25b 100644 --- a/tests/unit/sampletones_application/utils/gui/test_modal_queue.py +++ b/tests/unit/sampletones_application/utils/gui/test_modal_queue.py @@ -256,6 +256,40 @@ def answer() -> None: assert screen.built == [DIALOG, PROMPT, SECOND] +class TestAHandOffThatRaises: + """A hand-off that fails leaves the conversation and the line to go on in a coming frame.""" + + @staticmethod + def broken() -> None: + raise RuntimeError("the answer failed") + + def test_the_hand_offs_after_it_run_in_a_coming_frame(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + screen.open(SECOND) + ModalQueue.leave(FIRST) + ModalQueue.hand_off(self.broken) + ModalQueue.hand_off(lambda: screen.open(PROMPT)) + + with pytest.raises(RuntimeError): + held_frames.render() + assert screen.built == [FIRST] + held_frames.render() + + assert screen.built == [FIRST, PROMPT] + + def test_the_line_moves_on_behind_it(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + screen.open(SECOND) + ModalQueue.leave(FIRST) + ModalQueue.hand_off(self.broken) + + with pytest.raises(RuntimeError): + held_frames.render() + held_frames.render() + + assert screen.built == [FIRST, SECOND] + + class TestTheSnapshot: """A reader outside the line sees which window stands, which wait, and whether the line moves.""" From b154964f40d5ed0649f69dc228b01bc45a8b52fb Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 16:09:22 +0200 Subject: [PATCH 54/84] Changed: every way of listing recordings refusing only during a conversion, with one notice --- src/sampletones_application/application.py | 49 +--- .../coordinators/tabs/main.py | 115 ++++++--- src/sampletones_application/tags/main.py | 12 +- .../ui/panels/main/explorer.py | 31 +-- src/sampletones_config/lang/en.yaml | 4 +- .../coordinators/tabs/test_main.py | 241 +++++++++++------- .../test_application_gestures.py | 1 + .../ui/panels/main/test_explorer_controls.py | 30 +-- 8 files changed, 256 insertions(+), 227 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 8ae497f82..09aea49d1 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -146,12 +146,6 @@ waiting, ) from sampletones_application.utils.callbacks.queue import CallbackQueue -from sampletones_application.utils.file_dialogs.api import ( - open_file_dialog, - select_directory_dialog, -) -from sampletones_application.utils.file_dialogs.filter import FileFilter -from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.fps import FPSTimer from sampletones_application.utils.frame_limiter import FrameLimiter from sampletones_application.utils.gui.callbacks import run_held_callbacks @@ -200,7 +194,6 @@ ) from sampletones_shared.exceptions import PlaybackError from sampletones_shared.logger import logger -from sampletones_shared.paths.extensions import EXT_FILES_AUDIO from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback @@ -773,8 +766,8 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: exit=self._exiting, undo=self._sequencer_tab.undo, redo=self._sequencer_tab.redo, - reconstruct_file=self._reconstruct_file_dialog, - reconstruct_directory=self._reconstruct_directory_dialog, + reconstruct_file=self._main_tab.reconstruct_file_dialog, + reconstruct_directory=self._main_tab.reconstruct_directory_dialog, load_generation_settings=self._config_coordinator.load_dialog, save_generation_settings=self._config_coordinator.save_dialog, open_reconstruction=self._reconstruction_opening, @@ -1088,36 +1081,6 @@ def _toggle_auto_expand_favorite_directories(self) -> None: ) self._update_menu() - def _reconstruct_file_dialog(self) -> None: - if self._is_operation_active(): - logger.warning("A conversion or library generation is already in progress; cannot start a new one") - return - - filepath = open_file_dialog( - title=self.language_manager["global.dialog.title.reconstruct_file"], - initial_directory=self.session_manager.get_audio_input_path(), - filters=( - FileFilter.for_extensions( - self.language_manager["global.dialog.filter.audio"], - EXT_FILES_AUDIO, - ), - ), - ) - - self._handle_reconstruct_file(filepath) - - def _reconstruct_directory_dialog(self) -> None: - if self._is_operation_active(): - logger.warning("A conversion or library generation is already in progress; cannot start a new one") - return - - directory = select_directory_dialog( - title=self.language_manager["global.dialog.title.reconstruct_directory"], - initial_directory=self.session_manager.get_audio_input_path(), - ) - - self._handle_reconstruct_directory(directory) - def _is_converter_panel_visible(self) -> bool: if self._main_tab is None: return False @@ -1194,20 +1157,12 @@ def _load_library(self, filepath: Path) -> None: self._set_current_tab(Tab.INSTRUCTIONS) self._update_menu() - @ignore_none_path - def _handle_reconstruct_file(self, filepath: Path) -> None: - self._main_tab.request_reconstruct_file(filepath) - def _reconstruct_directory(self, directory_path: Path) -> None: self._main_tab.take_up_path(directory_path) self.session_manager.set_audio_input_path(directory_path) self._set_current_tab(Tab.MAIN) self._update_menu() - @ignore_none_path - def _handle_reconstruct_directory(self, directory_path: Path) -> None: - self._main_tab.request_reconstruct_directory(directory_path) - def _on_playback_error(self, exception: Exception) -> None: """Reports a playback the device refused, on the render thread whichever thread heard of it. diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 3ef3ff403..0c33bce62 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional, Tuple +from typing import List, Optional, Tuple import dearpygui.dearpygui as dpg @@ -38,11 +38,11 @@ TAG_MAIN_CONFIG_PANEL_CONFIG_CELL, TAG_MAIN_CONFIG_TABLE_CONFIG_ROW, TAG_MAIN_CONVERTER_DIALOG_CANCEL, + TAG_MAIN_CONVERTER_DIALOG_CONVERSION_RUNNING, TAG_MAIN_CONVERTER_DIALOG_DISCARD_STEMS, TAG_MAIN_CONVERTER_DIALOG_LOAD, TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, TAG_MAIN_CONVERTER_PANEL, - TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW, TAG_MAIN_EXPLORER_PANEL, TAG_MAIN_SOURCE_PANEL, @@ -57,7 +57,8 @@ from sampletones_application.ui.panels.main.converter.panel import GUIConverterPanel from sampletones_application.ui.panels.main.explorer import GUIExplorerPanel from sampletones_application.ui.panels.main.source.panel import GUISourceSettingsPanel -from sampletones_application.utils.file_dialogs.api import select_directory_dialog +from sampletones_application.utils.file_dialogs.api import open_file_dialog, select_directory_dialog +from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dpg import dpg_configure_item @@ -75,6 +76,7 @@ from sampletones_core.library import library_state from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILES_AUDIO from sampletones_shared.types.callback import VoidCallback _LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_MAIN, SUF_PANEL_LEFT) @@ -297,7 +299,6 @@ def _wire_explorer(self) -> None: self._explorer_panel.set_callbacks( on_directory_add_requested=self._on_directory_add_requested, on_file_add_requested=self._on_file_add_requested, - can_add_stems=self._can_add_stems, on_reconstruct_file=self.request_reconstruct_file, on_reconstruct_directory=self.request_reconstruct_directory, on_load_reconstruction=self._hooks.on_load_reconstruction, @@ -381,18 +382,68 @@ def _repaint_converter(self, view_model: ConverterViewModel) -> None: self._update_source_panel_view() self._hooks.on_busy_state_changed() + def reconstruct_file_dialog(self) -> None: + """Asks for a recording to list for a Reconstruct, once the list takes changes.""" + self._changing_the_list(self._choose_recording_to_reconstruct) + + def reconstruct_directory_dialog(self) -> None: + """Asks for a folder to list for a Reconstruct, once the list takes changes.""" + self._changing_the_list(self._choose_folder_to_reconstruct) + def request_reconstruct_file(self, filepath: Path) -> None: """Lists the recording a Reconstruct named, asking first where it would replace a mix.""" - if self._notify_converter_running(): - return - - self._giving_way_to_one_apiece(lambda: self._hooks.on_reconstruct_file(filepath)) + self._changing_the_list(lambda: self._reconstruct_file(filepath)) def request_reconstruct_directory(self, directory_path: Path) -> None: """Lists the folder a Reconstruct named, asking first where it would replace a mix.""" - if self._notify_converter_running(): + self._changing_the_list(lambda: self._reconstruct_directory(directory_path)) + + def _changing_the_list(self, gesture: VoidCallback) -> None: + """Runs a gesture that lists recordings, or tells the reader the conversion holds the list. + + Every door to the list comes here: Reconstruct in the menu and in the browser, Add as stem, + Add folder, Ctrl-click and a double-click. The list refuses changes only while the converter's + own run holds it. A library generation, a render or an export leaves it open, since listing + starts nothing, and the busy authority keeps Convert greyed until they end. A gesture that + lands later, once a folder is read or a question is answered, comes here again, since a run + may have started in between. + """ + if self._converter_logic.live: + gesture() return + self._dialogs.show_info( + TAG_MAIN_CONVERTER_DIALOG_CONVERSION_RUNNING, + self._language_manager["main.converter.message.conversion_running"], + self._language_manager["main.converter.title.conversion_running_dialog"], + ) + + def _choose_recording_to_reconstruct(self) -> None: + filepath = open_file_dialog( + title=self._language_manager["global.dialog.title.reconstruct_file"], + initial_directory=self._session_manager.get_audio_input_path(), + filters=( + FileFilter.for_extensions( + self._language_manager["global.dialog.filter.audio"], + EXT_FILES_AUDIO, + ), + ), + ) + self._reconstruct_file(filepath) + + def _choose_folder_to_reconstruct(self) -> None: + directory_path = select_directory_dialog( + title=self._language_manager["global.dialog.title.reconstruct_directory"], + initial_directory=self._session_manager.get_audio_input_path(), + ) + self._reconstruct_directory(directory_path) + + @ignore_none_path + def _reconstruct_file(self, filepath: Path) -> None: + self._giving_way_to_one_apiece(lambda: self._hooks.on_reconstruct_file(filepath)) + + @ignore_none_path + def _reconstruct_directory(self, directory_path: Path) -> None: self._giving_way_to_one_apiece(lambda: self._hooks.on_reconstruct_directory(directory_path)) def _giving_way_to_one_apiece(self, take_up: VoidCallback) -> None: @@ -406,7 +457,7 @@ def _giving_way_to_one_apiece(self, take_up: VoidCallback) -> None: take_up() return - self._confirm_discarding_stems(take_up) + self._confirm_discarding_stems(lambda: self._changing_the_list(take_up)) def _confirm_discarding_stems(self, on_confirm: VoidCallback) -> None: self._dialogs.show_confirmation( @@ -445,19 +496,6 @@ def _confirm_overwriting_target(self, targets: Tuple[Path, ...]) -> None: path=targets[0] if one else None, ) - def _notify_converter_running(self) -> bool: - if not self._hooks.is_operation_active(): - return False - - logger.warning("Conversion is already running. Wait or cancel the current operation.") - self._dialogs.show_info( - TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, - self._language_manager["main.explorer.message.converter_running_msg"], - self._language_manager["main.explorer.title.converter_running_dialog"], - ) - - return True - def _on_conversion_success(self, success: ConversionSuccess) -> None: self._hooks.on_refresh_trees() if success.is_single: @@ -496,19 +534,16 @@ def _request_output(self, output: OutputKind) -> None: self._stem_selection_window.open( self._converter_logic.gathered_rows, self._converter_logic.mix_ceiling, - self._converter_logic.mix_only, + self._answer_mix, ) - def _can_add_stems(self) -> bool: - """The converter is free to gather recordings into a stems conversion.""" - return not self._hooks.is_operation_active() + def _answer_mix(self, paths: List[Path]) -> None: + """Takes the recordings the reader picked to mix, once the list takes changes.""" + self._changing_the_list(lambda: self._converter_logic.mix_only(paths)) def _on_file_add_requested(self, filepath: Path) -> None: - """Gathers one recording into a stems conversion, opening one where none is being built.""" - if self._hooks.is_operation_active(): - return - - self._converter_logic.gather_recordings([filepath]) + """Gathers one recording into the list, opening a stems conversion where none is being built.""" + self._changing_the_list(lambda: self._converter_logic.gather_recordings([filepath])) def _on_directory_add_requested(self, directory_path: Path) -> None: """Reads what a folder holds, and gathers it once the reading is done. @@ -516,10 +551,7 @@ def _on_directory_add_requested(self, directory_path: Path) -> None: A tree is read one entry at a time and a large one takes seconds, so the reading runs beside the interface and says how far it has got. """ - if self._hooks.is_operation_active(): - return - - self._folder_scan.start(directory_path, self._gather_read) + self._changing_the_list(lambda: self._folder_scan.start(directory_path, self._gather_read)) def _on_scan_failed(self, exception: Exception) -> None: """Takes the wait away and shows the failure that ended the reading, which added nothing.""" @@ -533,6 +565,10 @@ def _gather_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: self._nothing_below(directory_path) return + self._changing_the_list(lambda: self._gather_found(directory_path, found)) + + def _gather_found(self, directory_path: Path, found: Tuple[Path, ...]) -> None: + """Gathers the recordings a folder holds, asking first which to mix where a mix overflows.""" if self._mixing_beyond_room(found): return @@ -558,7 +594,7 @@ def _take_up_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: self._nothing_below(directory_path) return - self._converter_logic.take_up_folder(directory_path, found) + self._changing_the_list(lambda: self._converter_logic.take_up_folder(directory_path, found)) def _mixing_beyond_room(self, found: Tuple[Path, ...]) -> bool: """Whether what was read brings in more than the mix has room for, which is a question. @@ -578,7 +614,7 @@ def _mixing_beyond_room(self, found: Tuple[Path, ...]) -> bool: self._stem_selection_window.open( self._converter_logic.gathered_rows + offered, self._converter_logic.mix_ceiling, - self._converter_logic.mix_only, + self._answer_mix, ) return True @@ -779,8 +815,7 @@ def refresh_converter_view(self) -> None: def take_up_path(self, path: Path) -> None: """Lists what a Reconstruct named, a recording or a folder, for the reader to convert one apiece. - A folder is read before it is listed, which is work the reader watches rather than waits - blindly through. + A folder is read before it is listed, and the reader watches the reading count what it finds. """ if not path.is_dir(): self._converter_logic.take_up_recording(path) diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index 118c4b24d..21d497c6c 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -56,12 +56,6 @@ Widget.DIALOG, "nothing_below", ) -TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING = TagName( - Page.MAIN, - Panel.EXPLORER, - Widget.DIALOG, - "converter_running", -) TAG_MAIN_EXPLORER_BUTTON_REFRESH = TagName( Page.MAIN, Panel.EXPLORER, @@ -325,6 +319,12 @@ Widget.DIALOG, "discard_stems", ) +TAG_MAIN_CONVERTER_DIALOG_CONVERSION_RUNNING = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.DIALOG, + "conversion_running", +) TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET = TagName( Page.MAIN, Panel.CONVERTER, diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 2d4850910..c705e0fab 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Callable, List, Optional, Protocol, Tuple +from typing import Any, List, Optional, Protocol, Tuple import dearpygui.dearpygui as dpg @@ -94,7 +94,6 @@ def __init__( self.on_directory_add_requested: Optional[PathCallback] = None self.on_file_add_requested: Optional[PathCallback] = None - self.can_add_stems: Optional[Callable[[], bool]] = None self.on_reconstruct_directory: Optional[PathCallback] = None self.on_reconstruct_file: Optional[PathCallback] = None self.on_load_reconstruction: Optional[PathCallback] = None @@ -287,22 +286,18 @@ def _audio_node_clicked(self, node: FileSystemNode) -> None: A plain click previews the recording and leaves the conversion as it stands, so walking the browser to hear what a file holds costs the run nothing. Ctrl is the gathering gesture - throughout the browser, so it reaches a recording the same way it reaches a folder and does - what **Add as stem** does, opening a stems conversion where none is being built; where the - converter is busy it is a plain click, and the recording plays. + throughout the browser, so it reaches a recording the same way it reaches a folder and asks + what **Add as stem** asks, which the converter answers. """ - if Modifier.CTRL in capture_modifiers() and self._gather_audio_node(node): + if Modifier.CTRL in capture_modifiers(): + self._gather_audio_node(node) return self._logic.request_autoplay(node) - def _gather_audio_node(self, node: FileSystemNode) -> bool: - """Hands a recording to the converter where it is free to take one, saying whether it went.""" - if not self.query(self.can_add_stems, default=False): - return False - + def _gather_audio_node(self, node: FileSystemNode) -> None: + """Asks the converter to gather a recording, as **Add as stem** does.""" self.call(self.on_file_add_requested, node.filepath) - return True def _on_file_node_double_clicked( self, @@ -357,17 +352,17 @@ def _directory_node_clicked( ) -> None: """Answers a click on a folder: Ctrl gathers its recordings, and a plain click opens it. - Gathering a folder reads every recording below it, which is work a reader asks for rather - than work that follows them around the browser. Ctrl does what **Add folder** does, so the - folder joins the conversion without the reader leaving the row; a plain click opens the - folder and leaves the conversion as it stands, and so does every click while the converter - is busy. + Gathering a folder reads every recording below it, so it waits for the reader to ask for + it. Ctrl asks what **Add folder** asks, so the + folder joins the conversion without the reader leaving the row, and the converter answers + for whether its list takes it. A plain click opens the folder and leaves the conversion as + it stands. """ has_content = self._explorer_logic.has_relevant_content(node.filepath) if not has_content: return - if Modifier.CTRL in capture_modifiers() and self.query(self.can_add_stems, default=False): + if Modifier.CTRL in capture_modifiers(): self.call(self.on_directory_add_requested, node.filepath) return diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 93504bd5e..af337cacb 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -348,8 +348,6 @@ main.explorer.label.context_set_output_directory: "Set as output directory" main.explorer.message.status_node_audio_no_autoplay: "Double-click to add audio to the converter. Right-click to open context menu." main.explorer.message.status_node_audio: "Click to play audio. Double-click to add it to the converter. Right-click to open context menu." main.explorer.message.status_refresh: "Rescan the filesystem for audio files." -main.explorer.message.converter_running_msg: "A conversion is already running. Please wait for it to complete or cancel the current operation before starting a new one." -main.explorer.title.converter_running_dialog: "Conversion in progress" # ============================================================================= # Main tab — Config panel @@ -416,6 +414,8 @@ main.converter.template.scan_progress: "Found {count} recordings in {name}" main.converter.message.scan_opening: "Looking through {name}..." main.converter.message.scan_nothing_below: "There are no recordings in this folder." main.converter.message.scan_failed: "Can't read this folder, so no recordings were added." +main.converter.message.conversion_running: "Wait for the conversion to finish before you add recordings." +main.converter.title.conversion_running_dialog: "Conversion running" main.converter.label.stop_scan_button: "Stop" main.converter.title.progress_dialog: "Reconstruction progress" main.converter.title.load_dialog: "Reconstruction complete" diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 3c5de1d86..41ddd76db 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -1,6 +1,7 @@ +from dataclasses import dataclass from pathlib import Path -from typing import Final, Tuple -from unittest.mock import MagicMock +from typing import Callable, Final, Tuple +from unittest.mock import MagicMock, patch import pytest @@ -14,17 +15,19 @@ from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION from sampletones_application.tags.main import ( TAG_MAIN_CONVERTER_DIALOG_CANCEL, + TAG_MAIN_CONVERTER_DIALOG_CONVERSION_RUNNING, TAG_MAIN_CONVERTER_DIALOG_LOAD, TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, - TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, ) from sampletones_core.constants.enums import ChannelName from tests.suite.application import settled +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase from tests.suite.files import LOCKED_FOLDER, held_at, requires_folder_permissions from tests.suite.language import FakeLanguageManager -CONVERTER_RUNNING_MESSAGE_KEY: Final[str] = "main.explorer.message.converter_running_msg" -CONVERTER_RUNNING_TITLE_KEY: Final[str] = "main.explorer.title.converter_running_dialog" +CONVERSION_RUNNING_MESSAGE_KEY: Final[str] = "main.converter.message.conversion_running" +CONVERSION_RUNNING_TITLE_KEY: Final[str] = "main.converter.title.conversion_running_dialog" LOAD_FILE_MESSAGE_KEY: Final[str] = "main.converter.message.load_file_prompt" LOAD_BUTTON_KEY: Final[str] = "main.converter.label.load_button" OPEN_BUTTON_KEY: Final[str] = "main.converter.label.open_button" @@ -34,6 +37,7 @@ NOTHING_BELOW_KEY: Final[str] = "main.converter.message.scan_nothing_below" EXIT_CONVERSION_MESSAGE_KEY: Final[str] = "global.dialog.message.exit_conversion_in_progress" EXIT_LABEL_KEY: Final[str] = "global.dialog.label.exit" +MAIN_MODULE: Final[str] = "sampletones_application.coordinators.tabs.main" def _hooks(*, operation_active: bool) -> MainTabHooks: @@ -53,93 +57,177 @@ def _hooks(*, operation_active: bool) -> MainTabHooks: ) -def _coordinator(*, operation_active: bool) -> MainTabCoordinator: - """A coordinator with only the state the reconstruct guards touch, bypassing the heavy - constructor.""" +def _coordinator(*, operation_active: bool, converting: bool = False) -> MainTabCoordinator: + """A coordinator with only the state the doors to the list touch, bypassing the heavy + constructor. + + ``converting`` is the converter's own run holding the list, and ``operation_active`` the busy + authority, which a conversion and every other exclusive operation answer.""" coordinator = MainTabCoordinator.__new__(MainTabCoordinator) - coordinator._hooks = _hooks(operation_active=operation_active) + coordinator._hooks = _hooks(operation_active=operation_active or converting) coordinator._dialogs = MagicMock() coordinator._language_manager = FakeLanguageManager() + coordinator._session_manager = MagicMock() coordinator._converter_logic = MagicMock() + coordinator._converter_logic.live = not converting coordinator._converter_logic.mixes = False coordinator._converter_logic.gathered_paths = () + coordinator._folder_scan = MagicMock() + coordinator._scan_window = MagicMock() return coordinator -class TestConverterRunningNotice: - """The busy-authority guard at the intent entry point: an active exclusive operation raises - the converter-running notice and reports the caller must decline; an idle authority stays - silent so the caller proceeds.""" +def _refused(coordinator: MainTabCoordinator) -> bool: + """Whether the one notice saying the run holds the list was shown, and nothing else.""" + coordinator._dialogs.show_info.assert_called_once_with( + TAG_MAIN_CONVERTER_DIALOG_CONVERSION_RUNNING, + CONVERSION_RUNNING_MESSAGE_KEY, + CONVERSION_RUNNING_TITLE_KEY, + ) + return True - def test_active_operation_notifies_and_reports_true(self) -> None: - coordinator = _coordinator(operation_active=True) - assert coordinator._notify_converter_running() is True +Door = Callable[[MainTabCoordinator, Path], None] +Reached = Callable[[MainTabCoordinator], bool] - coordinator._dialogs.show_info.assert_called_once_with( - TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, - CONVERTER_RUNNING_MESSAGE_KEY, - CONVERTER_RUNNING_TITLE_KEY, - ) - def test_idle_reports_false_silently(self) -> None: - coordinator = _coordinator(operation_active=False) +def _menu_reconstruct_file(coordinator: MainTabCoordinator, path: Path) -> None: + with patch(f"{MAIN_MODULE}.open_file_dialog", return_value=path): + coordinator.reconstruct_file_dialog() - assert coordinator._notify_converter_running() is False - coordinator._dialogs.show_info.assert_not_called() +def _menu_reconstruct_directory(coordinator: MainTabCoordinator, path: Path) -> None: + with patch(f"{MAIN_MODULE}.select_directory_dialog", return_value=path): + coordinator.reconstruct_directory_dialog() -class TestTheChannelKeys: - """A channel key reaches the recording picked out on the terms the list's own keys keep.""" +class TestEveryDoorToTheList(BaseTestSuite): + """Every door that lists recordings meets one rule: the list refuses changes only while the + converter's own run holds it, with one notice. - @pytest.mark.parametrize("keys_active", [True, False], ids=["open", "put-away"]) - def test_a_key_reaches_the_pick_only_while_the_list_answers_keys(self, keys_active: bool) -> None: - coordinator = _coordinator(operation_active=False) - coordinator._converter_panel = MagicMock(keys_active=keys_active) + Listing starts nothing, so a library generation, a render or an export leaves the list open, + and the busy authority keeps Convert greyed until it ends. Ctrl-click and a double-click reach + the same door Add as stem and Add folder do, since they gather too. + """ - coordinator.toggle_channel(ChannelName.TRIANGLE) + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + door: Door + reached: Reached + + test_cases = ( + TestCase( + label="Reconstruct file... in the menu", + door=_menu_reconstruct_file, + reached=lambda coordinator: coordinator._hooks.on_reconstruct_file.called, + ), + TestCase( + label="Reconstruct folder... in the menu", + door=_menu_reconstruct_directory, + reached=lambda coordinator: coordinator._hooks.on_reconstruct_directory.called, + ), + TestCase( + label="Reconstruct file in the browser", + door=MainTabCoordinator.request_reconstruct_file, + reached=lambda coordinator: coordinator._hooks.on_reconstruct_file.called, + ), + TestCase( + label="Reconstruct directory in the browser", + door=MainTabCoordinator.request_reconstruct_directory, + reached=lambda coordinator: coordinator._hooks.on_reconstruct_directory.called, + ), + TestCase( + label="Add as stem, Ctrl-click or double-click on a recording", + door=MainTabCoordinator._on_file_add_requested, + reached=lambda coordinator: coordinator._converter_logic.gather_recordings.called, + ), + TestCase( + label="Add folder or Ctrl-click on a folder", + door=MainTabCoordinator._on_directory_add_requested, + reached=lambda coordinator: coordinator._folder_scan.start.called, + ), + ) - assert coordinator._converter_logic.toggle_channel.called is keys_active + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_run_holding_the_list_refuses_it_with_one_notice(self, test_case: TestCase) -> None: + coordinator = _coordinator(operation_active=True, converting=True) + test_case.door(coordinator, Path("/audio/take.wav")) -class TestReconstructGuards: - """Reconstruction and conversion share the exclusive worker pool, so the reconstruct intents - decline while an operation runs and delegate to the wired callbacks when idle.""" + assert (_refused(coordinator), test_case.reached(coordinator)) == (True, False) - def test_file_request_declines_while_an_operation_is_active(self) -> None: + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_another_operation_leaves_the_list_open(self, test_case: TestCase) -> None: + """A library generation holds the busy authority, and the list takes the recording all the same.""" coordinator = _coordinator(operation_active=True) - coordinator.request_reconstruct_file(Path("/audio/sample.wav")) + test_case.door(coordinator, Path("/audio/take.wav")) - coordinator._hooks.on_reconstruct_file.assert_not_called() - coordinator._dialogs.show_info.assert_called_once() + assert (test_case.reached(coordinator), coordinator._dialogs.show_info.called) == (True, False) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_each_attempt_shows_the_notice_again(self, test_case: TestCase) -> None: + coordinator = _coordinator(operation_active=True, converting=True) + + test_case.door(coordinator, Path("/audio/take.wav")) + test_case.door(coordinator, Path("/audio/take.wav")) + + assert coordinator._dialogs.show_info.call_count == 2 + + +class TestAGestureLandingAfterARunStarted: + """A gesture that lands later meets the rule again where it lands, since a run may have started in + between: a folder read coming back, and a question answered.""" - def test_file_request_delegates_when_idle(self) -> None: + def test_a_folder_read_for_add_folder_lists_nothing(self) -> None: + coordinator = _coordinator(operation_active=True, converting=True) + + coordinator._gather_read(Path("/audio"), (Path("/audio/take.wav"),)) + + assert (_refused(coordinator), coordinator._converter_logic.gather_folder.called) == (True, False) + coordinator._scan_window.close.assert_called_once_with() + + def test_a_folder_read_for_a_reconstruct_lists_nothing(self) -> None: + coordinator = _coordinator(operation_active=True, converting=True) + + coordinator._take_up_read(Path("/audio"), (Path("/audio/take.wav"),)) + + assert (_refused(coordinator), coordinator._converter_logic.take_up_folder.called) == (True, False) + coordinator._scan_window.close.assert_called_once_with() + + def test_replacing_the_mix_answered_after_a_run_started_lists_nothing(self) -> None: coordinator = _coordinator(operation_active=False) - filepath = Path("/audio/sample.wav") + coordinator._converter_logic.mixes = True + coordinator._converter_logic.gathered_paths = (Path("/audio/a.wav"),) + coordinator.request_reconstruct_file(Path("/audio/b.wav")) + coordinator._converter_logic.live = False - coordinator.request_reconstruct_file(filepath) + coordinator._dialogs.show_confirmation.call_args.args[3]() - coordinator._hooks.on_reconstruct_file.assert_called_once_with(filepath) - coordinator._dialogs.show_info.assert_not_called() + assert (_refused(coordinator), coordinator._hooks.on_reconstruct_file.called) == (True, False) - def test_directory_request_declines_while_an_operation_is_active(self) -> None: - coordinator = _coordinator(operation_active=True) + def test_a_mix_picked_after_a_run_started_changes_nothing(self) -> None: + coordinator = _stems_coordinator(mixes=True, folder_rows=_rows_holding(MAX_STEM_SOURCES, 1)) + coordinator._gather_read(Path("/audio"), tuple(Path(f"/audio/{index}.wav") for index in range(9))) + coordinator._converter_logic.live = False + _rows, _room, answer = coordinator._stem_selection_window.open.call_args.args - coordinator.request_reconstruct_directory(Path("/audio")) + answer((Path("/audio/0.wav"),)) - coordinator._hooks.on_reconstruct_directory.assert_not_called() - coordinator._dialogs.show_info.assert_called_once() + assert (_refused(coordinator), coordinator._converter_logic.mix_only.called) == (True, False) + + +class TestTheChannelKeys: + """A channel key reaches the recording picked out on the terms the list's own keys keep.""" - def test_directory_request_delegates_when_idle(self) -> None: + @pytest.mark.parametrize("keys_active", [True, False], ids=["open", "put-away"]) + def test_a_key_reaches_the_pick_only_while_the_list_answers_keys(self, keys_active: bool) -> None: coordinator = _coordinator(operation_active=False) - directory = Path("/audio") + coordinator._converter_panel = MagicMock(keys_active=keys_active) - coordinator.request_reconstruct_directory(directory) + coordinator.toggle_channel(ChannelName.TRIANGLE) - coordinator._hooks.on_reconstruct_directory.assert_called_once_with(directory) - coordinator._dialogs.show_info.assert_not_called() + assert coordinator._converter_logic.toggle_channel.called is keys_active def _success_coordinator() -> MainTabCoordinator: @@ -222,10 +310,10 @@ def _stems_coordinator( ) -> MainTabCoordinator: coordinator = MainTabCoordinator.__new__(MainTabCoordinator) coordinator._hooks = _hooks(operation_active=operation_active) - coordinator._notify_converter_running = lambda: operation_active coordinator._dialogs = MagicMock() coordinator._language_manager = FakeLanguageManager() coordinator._converter_logic = MagicMock() + coordinator._converter_logic.live = True coordinator._converter_logic.mixes = mixes coordinator._converter_logic.gathered_paths = gathered coordinator._converter_logic.source_count = len(gathered) @@ -294,7 +382,8 @@ def test_a_list_longer_than_a_mix_holds_asks_which_to_mix(self) -> None: rows, room, answer = coordinator._stem_selection_window.open.call_args.args assert rows == listed assert room == MAX_STEM_SOURCES - assert answer == coordinator._converter_logic.mix_only + answer([gathered[0]]) + coordinator._converter_logic.mix_only.assert_called_once_with([gathered[0]]) def test_a_list_a_mix_holds_takes_effect_at_once(self) -> None: gathered = tuple(Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES)) @@ -319,13 +408,6 @@ def test_a_folder_joins_the_setup(self, tmp_path: Path) -> None: assert gathered == root assert {path.name for path in found} == {"take_00.wav", "take_01.wav"} - def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: - coordinator = _stems_coordinator(operation_active=True) - - coordinator._on_directory_add_requested(tmp_path) - - coordinator._converter_logic.gather_folder.assert_not_called() - def test_a_folder_holding_no_recordings_says_so(self, tmp_path: Path) -> None: """The reading is what knows what a folder holds, so the answer arrives when it comes back and the setup stands as it was.""" @@ -363,26 +445,6 @@ def test_a_recording_joins_the_list(self, tmp_path: Path) -> None: coordinator._converter_logic.gather_recordings.assert_called_once_with([recording]) - def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: - coordinator = _stems_coordinator(operation_active=True) - - coordinator._on_file_add_requested(tmp_path / "bass.wav") - - coordinator._converter_logic.gather_recordings.assert_not_called() - - -class TestModifierAddAvailability: - """The modifier click gathers a recording whenever the converter is free to take one.""" - - def test_a_gathered_list_takes_the_click(self) -> None: - assert _stems_coordinator()._can_add_stems() is True - - def test_a_classic_conversion_takes_the_click_and_opens_a_list(self) -> None: - assert _stems_coordinator(mixes=False)._can_add_stems() is True - - def test_a_busy_application_leaves_the_click_alone(self) -> None: - assert _stems_coordinator(operation_active=True)._can_add_stems() is False - OVERWRITE_TARGET_PROMPT_KEY: Final[str] = "main.converter.message.overwrite_target_prompt" OVERWRITE_TARGET_BUTTON_KEY: Final[str] = "main.converter.label.overwrite_target_button" @@ -551,7 +613,8 @@ def test_a_folder_overflowing_the_mix_asks_which_to_mix(self, tmp_path: Path) -> offered, room, answer = coordinator._stem_selection_window.open.call_args.args assert offered == coordinator._converter_logic.gathered_rows + rows assert room == MAX_STEM_SOURCES - assert answer == coordinator._converter_logic.mix_only + answer([Path("/audio/take.wav")]) + coordinator._converter_logic.mix_only.assert_called_once_with([Path("/audio/take.wav")]) def test_a_full_mix_is_offered_beside_what_the_folder_holds(self, tmp_path: Path) -> None: """A mix with no room left is answerable: letting one go is what makes room for another.""" @@ -578,14 +641,6 @@ def test_the_reading_is_put_on_screen(self, tmp_path: Path) -> None: coordinator._scan_window.open.assert_called_once_with(root) - def test_a_busy_application_leaves_the_folder_alone(self, tmp_path: Path) -> None: - coordinator = _stems_coordinator(operation_active=True, folder_rows=_rows_holding(1)) - - _add_folder(coordinator, _folder_of(tmp_path, 1)) - - coordinator._converter_logic.gather_folder.assert_not_called() - coordinator._stem_selection_window.open.assert_not_called() - class TestTheExitAsksAboutARunningConversion: """Exiting stops a running conversion, so the reader is asked first.""" diff --git a/tests/unit/sampletones_application/test_application_gestures.py b/tests/unit/sampletones_application/test_application_gestures.py index a07a5dc06..44726e8b8 100644 --- a/tests/unit/sampletones_application/test_application_gestures.py +++ b/tests/unit/sampletones_application/test_application_gestures.py @@ -23,6 +23,7 @@ "_config_coordinator", "_display_coordinator", "_keybindings_coordinator", + "_main_tab", "_shell", ) OWN_GESTURES: Final[Tuple[str, ...]] = ( diff --git a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py index 932caefe4..cc123d23d 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py @@ -212,14 +212,13 @@ def request_autoplay(self, node: FileSystemNode) -> None: class RecordingClick: """A panel wired to record where a click on a recording went.""" - def __init__(self, *, can_add_stems: bool) -> None: + def __init__(self) -> None: tree = explorer_tree() self.panel = build_panel(tree) self.node = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC / "song.wav")[0] self.autoplay = FakeAutoplayLogic() self.gathered: List[Path] = [] self.panel._logic = self.autoplay # type: ignore[assignment] - self.panel.can_add_stems = lambda: can_add_stems self.panel.on_file_add_requested = self.gathered.append def click(self, monkeypatch: pytest.MonkeyPatch, *, holding_ctrl: bool) -> None: @@ -236,10 +235,14 @@ def double_click(self) -> None: class TestClickingARecording: - """A plain click plays a recording; Ctrl and a double-click each gather it as a stem.""" + """A plain click plays a recording; Ctrl and a double-click each ask to gather it as a stem. + + The two gathering gestures always reach the converter, which answers for whether its list takes + the recording, so they ask the way Add as stem asks. + """ def test_a_plain_click_plays_the_recording_and_gathers_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: - clicked = RecordingClick(can_add_stems=True) + clicked = RecordingClick() clicked.click(monkeypatch, holding_ctrl=False) @@ -247,31 +250,16 @@ def test_a_plain_click_plays_the_recording_and_gathers_nothing(self, monkeypatch assert clicked.gathered == [] def test_holding_ctrl_gathers_the_recording_as_a_stem(self, monkeypatch: pytest.MonkeyPatch) -> None: - clicked = RecordingClick(can_add_stems=True) + clicked = RecordingClick() clicked.click(monkeypatch, holding_ctrl=True) assert clicked.gathered == [MUSIC / "song.wav"] assert clicked.autoplay.played == [] - def test_a_busy_converter_leaves_ctrl_the_plain_click(self, monkeypatch: pytest.MonkeyPatch) -> None: - clicked = RecordingClick(can_add_stems=False) - - clicked.click(monkeypatch, holding_ctrl=True) - - assert clicked.autoplay.played == [clicked.node] - assert clicked.gathered == [] - def test_a_double_click_gathers_the_recording(self) -> None: - clicked = RecordingClick(can_add_stems=True) + clicked = RecordingClick() clicked.double_click() assert clicked.gathered == [MUSIC / "song.wav"] - - def test_a_double_click_gathers_nothing_while_the_converter_is_busy(self) -> None: - clicked = RecordingClick(can_add_stems=False) - - clicked.double_click() - - assert clicked.gathered == [] From f211f8545260e239398efd3135615f5e484163f2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 16:13:22 +0200 Subject: [PATCH 55/84] Fixed: a folder listing only its names reading as one with no recordings --- .../reconstructions/converter/paths/utils.py | 10 ++++++++++ .../services/test_folder_scan.py | 18 ++++++++++++++++++ .../converter/paths/test_utils.py | 15 ++++++++++++++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index dc13a9076..b73df7ac2 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -1,3 +1,4 @@ +import errno import os from pathlib import Path from typing import AbstractSet, Dict, FrozenSet, Iterator, List, Tuple @@ -147,10 +148,19 @@ def walk_entries(input_directory: Path) -> Iterator[Path]: folder below the directory that the reader may not open is passed over with everything it holds, so one locked folder leaves the rest of the tree to the walk. + A POSIX directory opens its entries through its execute permission, so a directory may list its + names and keep its entries closed. ``os.access`` reads that permission, and it answers True on + Windows, whose folders carry none. + Raises: OSError: If the directory itself cannot be opened, which leaves the walk nothing to read. + PermissionError: If the directory lists its names and keeps its entries closed, which leaves + the walk nothing to read either. """ os.scandir(input_directory).close() + if not os.access(input_directory, os.X_OK): + raise PermissionError(errno.EACCES, os.strerror(errno.EACCES), str(input_directory)) + return input_directory.rglob("*") diff --git a/tests/unit/sampletones_application/services/test_folder_scan.py b/tests/unit/sampletones_application/services/test_folder_scan.py index 39724b0a3..aed9676f3 100644 --- a/tests/unit/sampletones_application/services/test_folder_scan.py +++ b/tests/unit/sampletones_application/services/test_folder_scan.py @@ -124,6 +124,24 @@ def test_the_folders_it_may_not_read_are_passed_over( assert len(found(reports)[0].recordings) == 3 + @requires_folder_permissions + def test_a_folder_listing_names_only_is_a_failure( + self, + service: FolderScanService, + reports: List[FolderScanResult], + tmp_path: Path, + ) -> None: + """The folder asked for lists its recordings and keeps them closed, so the reader hears that it + can't be read, as for a folder that can't be opened.""" + root = tree(tmp_path / "takes", 2) + + with held_at(root, NAMES_ONLY_FOLDER): + service.start(FolderScanRequest(root=root)) + + failure = reports[-1] + assert isinstance(failure, FolderScanError) + assert isinstance(failure.exception, PermissionError) + class TestWhatTheReaderIsTold: """The reader hears that the reading began and how far it has got.""" diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py index a84f31c2d..54d083477 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py @@ -1,3 +1,4 @@ +import errno from pathlib import Path from typing import List @@ -17,7 +18,7 @@ ) from sampletones_core.reconstructions.converter.paths.utils import ConfigDirectories from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION -from tests.suite.files import LOCKED_FOLDER, held_at, requires_folder_permissions +from tests.suite.files import LOCKED_FOLDER, NAMES_ONLY_FOLDER, held_at, requires_folder_permissions CHANNELS = frozenset(DEFAULT_CHANNELS) @@ -145,6 +146,18 @@ def test_a_folder_that_cannot_be_opened_raises(self, tmp_path: Path) -> None: with held_at(tmp_path, LOCKED_FOLDER), pytest.raises(PermissionError): walk_entries(tmp_path) + def test_a_folder_listing_names_only_raises_naming_itself(self, tmp_path: Path) -> None: + """A folder may list its names and keep its recordings closed, which leaves the walk nothing + to read, as a folder that cannot be opened does.""" + root = tmp_path / "names_only" + root.mkdir() + (root / "take.wav").touch() + + with held_at(root, NAMES_ONLY_FOLDER), pytest.raises(PermissionError) as raised: + walk_entries(root) + + assert (raised.value.errno, raised.value.filename) == (errno.EACCES, str(root)) + class TestFilterFiles: def test_includes_files_without_existing_output(self, tmp_path: Path) -> None: From ff63db0f5a81cc716faabdf57349c92081675f8f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 16:15:24 +0200 Subject: [PATCH 56/84] Corrected: guide passages and docstrings that drifted from what the converter and the reconstruction do --- docs/guide/converting.md | 5 +++-- docs/guide/reconstruction.md | 3 ++- .../logic/main/sources/list.py | 2 +- .../logic/sequencer/history_detail.py | 9 +++++---- .../ui/elements/graphs/waveform.py | 13 ++++++------- src/sampletones_shared/paths/extensions.py | 3 ++- 6 files changed, 19 insertions(+), 16 deletions(-) diff --git a/docs/guide/converting.md b/docs/guide/converting.md index db6d40826..e6f7150fa 100644 --- a/docs/guide/converting.md +++ b/docs/guide/converting.md @@ -102,8 +102,9 @@ that holds them all. Click the path to open it in your file manager. If the conv existing reconstruction, the app asks you first. When the conversion finishes, click **Load** to open the result on the **Reconstruction** tab, where -you can [listen to it and export it](reconstruction.md). After a conversion of several recordings, the -button opens the folder instead. +you can [listen to it and export it](reconstruction.md). When a conversion saves several +reconstructions, the button reads **Open** and takes you to the **Reconstruction** tab, where you pick +one in the **Browser**. If the reconstruction you have open has unsaved changes, **Load** asks whether to save them first. If the conversion wrote over that same reconstruction, you can't save your changes there. **Discard** diff --git a/docs/guide/reconstruction.md b/docs/guide/reconstruction.md index 3df2110de..a80ece047 100644 --- a/docs/guide/reconstruction.md +++ b/docs/guide/reconstruction.md @@ -22,7 +22,8 @@ changes away. **Cancel** keeps the one you have open. The **Source** card switches playback between **Reconstruction** and **Original audio**, so you can compare the two. Its **NES frequency** field retimes the reconstruction: type a new rate and press `Enter`. The field is locked for a reconstruction that belongs to a project, which follows the project's -rate. It is also locked for a reconstruction whose file is gone, until you save it to a file. +rate. It is also locked after you remove the open reconstruction, or its folder, in the **Browser**. +Save it to a file to unlock the field. The **Waveform** card has a checkbox for each channel, and keys `1` to `4` switch the same checkboxes. While an instrument is open, `2` and `3` play notes, so use `Alt+1` to `Alt+4`, which switch the diff --git a/src/sampletones_application/logic/main/sources/list.py b/src/sampletones_application/logic/main/sources/list.py index 86c003c17..a5f91d7c2 100644 --- a/src/sampletones_application/logic/main/sources/list.py +++ b/src/sampletones_application/logic/main/sources/list.py @@ -88,7 +88,7 @@ def folder_root_of(self, path: Path) -> Optional[Path]: """The root of the folder holding ``path``, which is the tree a run mirrors for it. A recording the reader named answers with nothing, and its reconstruction sits directly in - the directory the run's settings are named after. + the folder named after the channels it is handed. """ return self._roots.get(path) diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index b6b2b9e7a..93a0404d0 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -89,10 +89,11 @@ class SequencerHistoryDetail: read as two-digit hex; channels use the ``P``/``p``/``T``/``N`` abbreviations, concatenated when a sample-column gesture spans several channels. A gesture on the voice pool names its voice in the color of the kind that voice is, so a recording - and a hand-written one read apart down the list of entries. Every line about a gesture - on the pool itself, whether it adds, removes, replaces, renames, moves or duplicates a - voice, names the voice the same way, by its position and by its name, so a reader finds - it in the list whichever gesture the line records. A line about an edit inside a voice + and a hand-written one read apart down the list of entries. Every line about a voice + the pool holds, whether the gesture removes, replaces, renames, moves or duplicates it, + names the voice the same way, by its position and by its name, so a reader finds it in + the list whichever gesture the line records. A line about an added voice names it by + its name, which is what the adding hook carries. A line about an edit inside a voice names the voice by its position, beside what the edit touched. """ diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index 26a24a594..dda179456 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -478,9 +478,9 @@ def _display_layers( ) -> List[Union[ArrayLayer, InstructionLayer]]: """Builds the ordered waveform layers for the current data. - The original-audio layer joins the reconstruction layer only when the source audio is - present, so a detached reconstruction or one whose source file is missing shows the - approximation on its own. + The original-audio layer joins the reconstruction layer while the source audio is loaded, + so a reconstruction whose recordings are missing or unreadable shows the approximation on + its own. """ original_audio, approximation_data, _ = self._extract_reconstruction_layer_data( waveform_data, @@ -502,10 +502,9 @@ def update_waveform_data( ) -> None: """Redraws the loaded waveform from fresh data, keeping the view the reader left it at. - The plot draws the layers the fresh data displays, so a layer the data no longer carries - leaves it, and a layer joining it is drawn in the layers' order, with the audible source on - top. ``refit`` names the update a retune is: the audio's own length changed, so the view is - re-fitted to the new span. + The plot holds exactly the layers the fresh data displays, drawn in the layers' order with + the audible source on top. ``refit`` names the update a retune is: the audio's own length + changed, so the view is re-fitted to the new span. """ if not isinstance(self.current_data, WaveformData): return diff --git a/src/sampletones_shared/paths/extensions.py b/src/sampletones_shared/paths/extensions.py index ca597d69d..91dc48988 100644 --- a/src/sampletones_shared/paths/extensions.py +++ b/src/sampletones_shared/paths/extensions.py @@ -33,6 +33,7 @@ def is_audio_file(path: Path, extensions: Tuple[str, ...] = EXT_FILES_AUDIO) -> """Whether a path names a recording a run converts. A recording is a file the reader may inspect, so an entry of a folder that lists its names and - keeps the rest to itself reads as none. + keeps the rest to itself reads as none. ``os.path.isfile`` answers False for such an entry, + where ``Path.is_file`` raises ``PermissionError``, so a walk goes on past it. """ return path.suffix.lower() in extensions and os.path.isfile(path) From 6c11ebf778baa765e09e966bc4cf5366a16af6ae Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 16:08:43 +0200 Subject: [PATCH 57/84] Changed: the keybindings editor keeping every key of an action, a pressed key leading and a holder losing only what is taken --- docs/development/application/keyboard.md | 8 +- docs/guide/interface.md | 2 +- .../categories/elements/settings.py | 2 + .../config/session/application/shortcuts.py | 7 +- .../coordinators/keybindings.py | 145 ++++++---- .../ui/panels/dialogs/keybindings.py | 6 +- .../utils/gui/keyboard/combination.py | 27 +- .../utils/gui/shortcuts/draft.py | 115 +++++--- .../utils/gui/shortcuts/scheme.py | 82 +++--- .../utils/gui/shortcuts/written.py | 15 +- .../view_model/shared/keybindings.py | 17 +- src/sampletones_config/lang/en.yaml | 2 + .../interface/keybindings/test_every_key.py | 102 +++++++ .../keybindings/test_rebinding_undo.py | 24 +- tests/suite/screens/screen.py | 5 + .../suite/screens/views/keyboard_settings.py | 17 ++ tests/suite/shortcuts.py | 2 +- .../coordinators/test_keybindings.py | 140 +++++++--- .../ui/panels/dialogs/test_keybindings.py | 20 +- .../utils/gui/shortcuts/test_draft.py | 261 ++++++++++++++---- .../utils/gui/shortcuts/test_scheme.py | 46 ++- .../utils/gui/shortcuts/test_written.py | 18 +- 22 files changed, 787 insertions(+), 276 deletions(-) create mode 100644 tests/screens/interface/keybindings/test_every_key.py diff --git a/docs/development/application/keyboard.md b/docs/development/application/keyboard.md index 6e0872155..3e9365341 100644 --- a/docs/development/application/keyboard.md +++ b/docs/development/application/keyboard.md @@ -64,15 +64,17 @@ One key table (`utils/gui/keyboard/keys.py`) reads a key both ways: the name a f ### A preference layers over the shipped scheme -`ShortcutsConfig` holds the scheme name and the per-action overrides, both written the way a keybinding file writes them, so a preference outlives the build that stored it. `ShortcutCatalog.select` answers with the default for a scheme a build stopped shipping. An override is reported and left out when it names an action this build does not have, a key the table does not have, or a combination its category already gives away. One stale entry therefore costs only itself. +`ShortcutsConfig` holds the scheme name and the per-action overrides, both written the way a keybinding file writes them, so a preference outlives the build that stored it. `ShortcutCatalog.select` answers with the default for a scheme a build stopped shipping. An override lists every key of its action, main key first and joined by commas, and a key spells the comma as `Comma`. A stored single key is therefore an action with one key. An override is reported and left out whole when it names an action this build does not have, a key the table does not have, or a combination its category already gives away. One stale entry therefore costs only itself, and its action keeps the scheme's keys. A change reaches the running application through `ShortcutSource.on_bindings_changed`, the keyboard's analog of the palette switch ([`palette.md`](palette.md)). The dispatcher re-reads the keys, and the menus re-print their accelerators. Each registration names the action it fires, so a rebind has little to catch up. ### A scheme is edited through a draft -`ShortcutDraft` (`utils/gui/shortcuts/draft.py`) holds the scheme being edited together with the actions the reader has touched: the combination each was given, or nothing where it was left unbound. Only those actions reach the preference, and every other key follows the scheme beneath. +`ShortcutDraft` (`utils/gui/shortcuts/draft.py`) holds the scheme being edited together with the actions the reader has touched: every key each now answers, main key first, and none where it was left unbound. Only those actions reach the preference, and every other key follows the scheme beneath. -An assignment displaces. Giving an action a combination its category already answers takes the key from the holder in the same step, so every scheme a draft produces is valid. The dialog names the holder and asks before that step is taken. Cancel on that question brings the reader back to where they gave the keys: a written combination to the entry box, and a pressed one to its row, listening for the next press. A row reads as listening exactly while it takes the keys. The dialog edits the draft, and a commit activates it, so a reader rebinding Escape, Tab or Enter keeps the keys the dialog is operated by until they are done. +An edit changes the keys the reader names, and the action keeps the rest. A pressed key becomes the action's main key, the one the menus print, and the keys it had follow it. A list written in the entry box becomes exactly the action's keys. Clear unbinds every key. + +An assignment displaces only what it takes. Giving an action keys its category already answers takes those keys from their holders in the same step, so every scheme a draft produces is valid. A holder keeps its other keys, the next one becoming its main key. The dialog names every holder in one question and asks before that step is taken. Cancel on that question brings the reader back to where they gave the keys: a written list to the entry box, and a pressed key to its row, listening for the next press. A row reads as listening exactly while it takes the keys. The dialog edits the draft, and a commit activates it, so a reader rebinding Escape, Tab or Enter keeps the keys the dialog is operated by until they are done. ### A scheme belongs to a platform; an action does not diff --git a/docs/guide/interface.md b/docs/guide/interface.md index b1912afe4..c187b3885 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -25,7 +25,7 @@ Project properties belong to a project and are covered in the [sequencer guide]( ## Keyboard shortcuts -**View ▸ Keyboard shortcuts...** (`Ctrl+K`) lists everything you can do from the keyboard and lets you change any shortcut. Click an action's shortcut and press the keys you want. If another action already uses those keys, the app names that action and asks whether to reassign them. +**View ▸ Keyboard shortcuts...** (`Ctrl+K`) lists everything you can do from the keyboard and lets you change any shortcut. Click an action's shortcut and press the keys you want. They become its main shortcut, and the action keeps its other shortcuts. To set all of an action's shortcuts at once, select the action, type them in the **Shortcut** box separated by commas, and press `Enter`. If another action already uses those keys, the app names that action and asks whether to reassign them. **Reset to defaults** restores the original shortcuts. Your changes take effect when you click **OK**, and the app keeps them for the next time you start. diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 3fb69de2c..c99ff24dd 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -183,6 +183,8 @@ class KeybindingsElements(AbstractElement): DISCARD_BUTTON = "discard_button" KEEP_EDITING_BUTTON = "keep_editing_button" REASSIGN_CONFIRMATION = "reassign_confirmation" + REASSIGN_SEVERAL_CONFIRMATION = "reassign_several_confirmation" + ASSIGNMENT = "assignment" RESET_CONFIRMATION = "reset_confirmation" DISCARD_CONFIRMATION = "discard_confirmation" UNREADABLE_COMBINATION = "unreadable_combination" diff --git a/src/sampletones_application/config/session/application/shortcuts.py b/src/sampletones_application/config/session/application/shortcuts.py index b70a0391b..5123906d4 100644 --- a/src/sampletones_application/config/session/application/shortcuts.py +++ b/src/sampletones_application/config/session/application/shortcuts.py @@ -9,7 +9,8 @@ class ShortcutsConfig(BaseModel): """The keys the application answers to: the scheme it runs under and the actions rebound on it. A scheme names a whole set of keys the build ships, while an override rebinds one action on top - of it, so a reader who changes a single combination keeps every other key the scheme gives them. + of it, so a reader who changes a single action keeps every other key the scheme gives them. An + override lists every key of its action, so a stored single key reads as an action with one key. Both are stored by name — the same names a keybinding file writes — which lets a preference outlive the build that wrote it, since the names a build carries are what it reads back. @@ -24,7 +25,7 @@ class ShortcutsConfig(BaseModel): overrides: Dict[str, Optional[str]] = Field( default_factory=dict, description=( - "The combination each rebound action answers to, keyed by the action's name, " - "stating null for an action the reader left unbound." + "The keys each rebound action answers to, main key first and joined by commas, " + "keyed by the action's name, and null for an action the reader left unbound." ), ) diff --git a/src/sampletones_application/coordinators/keybindings.py b/src/sampletones_application/coordinators/keybindings.py index 174faaff5..9f99a36dd 100644 --- a/src/sampletones_application/coordinators/keybindings.py +++ b/src/sampletones_application/coordinators/keybindings.py @@ -1,4 +1,4 @@ -from typing import Optional, Tuple +from typing import Dict, Final, List, Optional, Tuple from sampletones_application.categories.elements.settings import ( KeybindingActionElements, @@ -15,7 +15,11 @@ ) from sampletones_application.ui.panels.dialogs.keybindings import GUIKeybindingsWindow from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import ( + KeyCombination, + combination_parts, + display_combinations, +) from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog from sampletones_application.utils.gui.shortcuts.draft import ShortcutDraft from sampletones_application.utils.gui.shortcuts.ids import ( @@ -32,8 +36,9 @@ ) from sampletones_shared.types.callback import VoidCallback -NO_COMBINATION: str = "" -NO_MESSAGE: str = "" +NO_KEYS: Final[str] = "" +NO_MESSAGE: Final[str] = "" +ASSIGNMENT_SEPARATOR: Final[str] = "\n" class KeybindingsCoordinator: @@ -44,10 +49,12 @@ class KeybindingsCoordinator: hands the draft's scheme to the source every action resolves against and writes the scheme name and the rebound actions to the session; canceling drops the draft and leaves the keys alone. - An assignment onto keys another action of the same scope holds is offered after a prompt naming - that action, which is then left unbound — one combination reaches one action within a scope. - Declining the prompt brings the dialog back where the reader gave the keys: a written - combination returns to the entry box, and a pressed one returns to its row listening for the + An action answers a list of keys, main key first. A pressed key becomes the action's main key + and the keys it had follow it, while a written list becomes exactly its keys. An assignment + onto keys other actions of the same scope hold is offered after one prompt naming each holder, + which then gives up those keys and keeps the rest, since one combination reaches one action + within a scope. Declining the prompt brings the dialog back where the reader gave the keys: a + written list returns to the entry box, and a pressed key returns to its row listening for the next press. """ @@ -110,55 +117,70 @@ def _select_action(self, name: str) -> None: self._window.update_view(self._view_model()) def _type_combination(self, text: str) -> None: - """Gives the selected action the keys a reader wrote out, reporting what reads as no key.""" + """Gives the selected action the list of keys a reader wrote out, reporting a part that reads + as no key. + """ shortcut_id = self._require_selected() - try: - combination = KeyCombination.parse(text) - except KeyError: - self._message = self._template(KeybindingsElements.UNREADABLE_COMBINATION).format(combination=text) - self._window.update_view(self._view_model()) + combinations = self._read_typed(text) + if combinations is None: return self._assign( shortcut_id, - combination, + combinations, on_declined=self._window.resume, ) + def _read_typed(self, text: str) -> Optional[Tuple[KeyCombination, ...]]: + """The keys a written list names, or ``None`` once a part naming no key is reported.""" + combinations: List[KeyCombination] = [] + for part in combination_parts(text): + try: + combination = KeyCombination.parse(part) + except KeyError: + self._message = self._template(KeybindingsElements.UNREADABLE_COMBINATION).format(combination=part) + self._window.update_view(self._view_model()) + return None + + combinations.append(combination) + + return tuple(combinations) + def _capture_combination(self, combination: KeyCombination) -> None: - """Gives the selected action the keys a reader pressed.""" + """Makes the key a reader pressed the selected action's main key, its other keys following.""" + shortcut_id = self._require_selected() self._assign( - self._require_selected(), - combination, + shortcut_id, + self._require_draft().keys_led_by(shortcut_id, combination), on_declined=self._window.listen_again, ) def _assign( self, shortcut_id: ShortcutId, - combination: KeyCombination, + combinations: Tuple[KeyCombination, ...], *, on_declined: VoidCallback, ) -> None: - """Assigns the combination, asking first where another action of the scope holds it. + """Assigns the keys, asking first where other actions of the scope hold any of them. Args: shortcut_id: The action the keys go to. - combination: The keys given. + combinations: Its keys, main key first. on_declined: What brings the dialog back once the reader declines to take the keys. """ draft = self._require_draft() self._message = NO_MESSAGE - claimant = draft.claimant(shortcut_id, combination) - if claimant is None: - self._apply(draft.assign(shortcut_id, combination)) + holders = draft.holders(shortcut_id, combinations) + if not holders: + self._apply(draft.assign(shortcut_id, combinations)) return self._window.yield_to( lambda: self._ask_to_reassign( shortcut_id, - combination, - claimant, + combinations, + holders, on_declined=on_declined, ), ) @@ -166,32 +188,61 @@ def _assign( def _ask_to_reassign( self, shortcut_id: ShortcutId, - combination: KeyCombination, - claimant: ShortcutId, + combinations: Tuple[KeyCombination, ...], + holders: Dict[KeyCombination, ShortcutId], *, on_declined: VoidCallback, ) -> None: - message = self._template(KeybindingsElements.REASSIGN_CONFIRMATION).format( - combination=combination.display(), - holder=self._action_label(claimant), - action=self._action_label(shortcut_id), - ) self._dialogs.show_confirmation( tag=TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN, title=self._title(KeybindingsElements.REASSIGN_CONFIRMATION), - message=message, - on_confirm=lambda: self._reassign(shortcut_id, combination), + message=self._reassign_question(shortcut_id, holders), + on_confirm=lambda: self._reassign(shortcut_id, combinations), on_cancel=on_declined, ok_label=self._label(KeybindingsElements.REASSIGN_BUTTON), ) - def _reassign(self, shortcut_id: ShortcutId, combination: KeyCombination) -> None: - """Takes the keys for the action the reader named, leaving the action that held them free.""" - self._apply(self._require_draft().assign(shortcut_id, combination)) + def _reassign_question( + self, + shortcut_id: ShortcutId, + holders: Dict[KeyCombination, ShortcutId], + ) -> str: + """The question naming each action that holds keys the reader is giving away. + + One key taken reads as one sentence; several list each key beside the action holding it. + """ + action = self._action_label(shortcut_id) + if len(holders) == 1: + ((combination, holder),) = holders.items() + return self._template(KeybindingsElements.REASSIGN_CONFIRMATION).format( + combination=combination.display(), + holder=self._action_label(holder), + action=action, + ) + + assignments = ASSIGNMENT_SEPARATOR.join( + self._template(KeybindingsElements.ASSIGNMENT).format( + combination=combination.display(), + holder=self._action_label(holder), + ) + for combination, holder in holders.items() + ) + return self._template(KeybindingsElements.REASSIGN_SEVERAL_CONFIRMATION).format( + assignments=assignments, + action=action, + ) + + def _reassign( + self, + shortcut_id: ShortcutId, + combinations: Tuple[KeyCombination, ...], + ) -> None: + """Gives the action the keys the reader named, each holder keeping the keys it has left.""" + self._apply(self._require_draft().assign(shortcut_id, combinations)) self._window.resume() def _clear(self) -> None: - """Leaves the selected action unbound, its keys free for another action to take.""" + """Leaves the selected action unbound, every key it held free for another action to take.""" self._message = NO_MESSAGE self._apply(self._require_draft().clear(self._require_selected())) @@ -258,7 +309,7 @@ def _view_model(self) -> KeybindingsViewModel: schemes=self._shortcut_catalog.names, scheme=self._scheme_name, selected=None if self._selected is None else self._selected.value, - combination=self._selected_combination(draft), + keys=self._selected_keys(draft), message=self._message, ) @@ -278,22 +329,18 @@ def _rows( KeybindingRow( action=shortcut_id.value, label=self._action_label(shortcut_id), - combination=self._displayed(draft.combination(shortcut_id)), + keys=display_combinations(draft.keys(shortcut_id)), ) for shortcut_id in ShortcutId if shortcut_id.category is category ) - def _selected_combination(self, draft: ShortcutDraft) -> str: - """The keys the entry box shows, empty while no action is selected.""" + def _selected_keys(self, draft: ShortcutDraft) -> str: + """The keys the entry box shows, joined by commas, and empty while no action is selected.""" if self._selected is None: - return NO_COMBINATION - - return self._displayed(draft.combination(self._selected)) + return NO_KEYS - @staticmethod - def _displayed(combination: Optional[KeyCombination]) -> str: - return NO_COMBINATION if combination is None else combination.display() + return display_combinations(draft.keys(self._selected)) def _action_label(self, shortcut_id: ShortcutId) -> str: """The name a reader finds an action under, which its element mirrors member for member.""" diff --git a/src/sampletones_application/ui/panels/dialogs/keybindings.py b/src/sampletones_application/ui/panels/dialogs/keybindings.py index b7c1e588e..82fdbe92e 100644 --- a/src/sampletones_application/ui/panels/dialogs/keybindings.py +++ b/src/sampletones_application/ui/panels/dialogs/keybindings.py @@ -203,7 +203,7 @@ def _create_row(self, row: KeybindingRow) -> None: ) dpg.add_selectable( tag=compose_tag(row_tag, SUF_SETTINGS_KEYBINDINGS_SHORTCUT), - label=row.combination, + label=row.keys, user_data=row.action, callback=self._on_shortcut_clicked, ) @@ -277,7 +277,7 @@ def _render(self) -> None: """Shows each action's keys, the standing selection, and what the filter leaves listed.""" view_model = self.view_model dpg_set_value(TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME, view_model.scheme) - dpg_set_value(TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT, view_model.combination) + dpg_set_value(TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT, view_model.keys) dpg_set_value(TAG_SETTINGS_KEYBINDINGS_TEXT_MESSAGE, view_model.message) for group in view_model.groups: self._render_group(group, view_model.selected) @@ -320,7 +320,7 @@ def _shortcut_label(self, row: KeybindingRow, *, is_selected: bool) -> str: if is_selected and self._capture is not None and self._capture.is_listening: return self._msg_capturing - return row.combination if row.combination else self._lbl_unbound + return row.keys if row.keys else self._lbl_unbound def _on_scheme_changed(self, _sender: Sender, app_data: str) -> None: self.call(self.on_scheme_selected, app_data) diff --git a/src/sampletones_application/utils/gui/keyboard/combination.py b/src/sampletones_application/utils/gui/keyboard/combination.py index 2075317e6..985aa1c5a 100644 --- a/src/sampletones_application/utils/gui/keyboard/combination.py +++ b/src/sampletones_application/utils/gui/keyboard/combination.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final, Set +from typing import Final, Iterable, Set, Tuple from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.keyboard.keys import ( @@ -18,6 +18,8 @@ ) COMBINATION_SEPARATOR: Final[str] = "+" +KEY_LIST_SEPARATOR: Final[str] = "," +KEY_LIST_JOINER: Final[str] = ", " @dataclass(frozen=True) @@ -84,3 +86,26 @@ def parse(cls, text: str) -> KeyCombination: key=key_code(COMBINATION_SEPARATOR.join(parts[index:])), modifiers=frozenset(modifiers), ) + + +def combination_parts(text: str) -> Tuple[str, ...]: + """The combinations a written list such as ``"Ctrl+Y, Ctrl+Shift+Z"`` names, each as it is written. + + The list is split at its commas and each part is trimmed, and a blank part names nothing. A key + spells the comma as ``Comma``, which keeps the comma free to separate the combinations. + """ + return tuple(part.strip() for part in text.split(KEY_LIST_SEPARATOR) if part.strip()) + + +def parse_combinations(text: str) -> Tuple[KeyCombination, ...]: + """The combinations a written list names, in the order it names them. + + Raises: + KeyError: If a part of the list names no key. + """ + return tuple(KeyCombination.parse(part) for part in combination_parts(text)) + + +def display_combinations(combinations: Iterable[KeyCombination]) -> str: + """The combinations as a list reads them, in their order and joined by commas.""" + return KEY_LIST_JOINER.join(combination.display() for combination in combinations) diff --git a/src/sampletones_application/utils/gui/shortcuts/draft.py b/src/sampletones_application/utils/gui/shortcuts/draft.py index 94a4f7253..297eef21c 100644 --- a/src/sampletones_application/utils/gui/shortcuts/draft.py +++ b/src/sampletones_application/utils/gui/shortcuts/draft.py @@ -3,10 +3,12 @@ from dataclasses import dataclass, replace from typing import Dict, Mapping, Optional, Tuple -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import KeyCombination, display_combinations from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme +Keys = Tuple[KeyCombination, ...] + @dataclass(frozen=True) class ShortcutDraft: @@ -14,13 +16,14 @@ class ShortcutDraft: An editor works on a draft and hands a scheme over once, which leaves the keys in force steady while Escape, Tab and Enter are themselves being rebound. A draft is kept as the actions the - reader touched and what they gave them, since an override replaces a whole binding: every other - action answers the scheme the build ships, and the touched entries are what a session stores. + reader touched and the whole list of keys each answers, main key first, since an override + replaces a whole binding: every other action answers the scheme the build ships, and the touched + entries are what a session stores. An empty list leaves an action unbound. """ base: ShortcutScheme - stored: Dict[ShortcutId, Optional[KeyCombination]] - edits: Dict[ShortcutId, Optional[KeyCombination]] + stored: Dict[ShortcutId, Keys] + edits: Dict[ShortcutId, Keys] @classmethod def open( @@ -36,14 +39,15 @@ def open( Args: base: The scheme as the build ships it, which the draft states its edits against. - overrides: The combination each rebound action answers to, keyed by the action's name. + overrides: The keys each rebound action answers to, joined by commas and keyed by the + action's name. Returns: ShortcutDraft: The draft holding what the session stores and that alone. """ preferred = base.with_overrides(overrides) - stored: Dict[ShortcutId, Optional[KeyCombination]] = { - shortcut_id: preferred.shortcut(shortcut_id).combination + stored: Dict[ShortcutId, Keys] = { + shortcut_id: preferred.shortcut(shortcut_id).combinations() for shortcut_id in ShortcutId if preferred.shortcut(shortcut_id) != base.shortcut(shortcut_id) } @@ -59,12 +63,27 @@ def is_dirty(self) -> bool: """Whether the draft holds keys the session has yet to store.""" return self.edits != self.stored - def combination(self, shortcut_id: ShortcutId) -> Optional[KeyCombination]: - """The keys an action answers to as the draft stands, ``None`` while it is unbound.""" + def keys(self, shortcut_id: ShortcutId) -> Keys: + """Every key an action answers to as the draft stands, main key first, and none while unbound.""" if shortcut_id in self.edits: return self.edits[shortcut_id] - return self.base.shortcut(shortcut_id).combination + return self.base.shortcut(shortcut_id).combinations() + + def keys_led_by( + self, + shortcut_id: ShortcutId, + combination: KeyCombination, + ) -> Keys: + """The keys an action answers once ``combination`` becomes its main key. + + A pressed key leads the list and the action keeps the keys it had after it, so pressing one + of its own keys moves that key to the front. + """ + return ( + combination, + *(key for key in self.keys(shortcut_id) if key != combination), + ) def claimant( self, @@ -88,41 +107,60 @@ def claimant( if other is shortcut_id or other.category is not shortcut_id.category: continue - if combination in self._claimed(other): + if combination in self.keys(other): return other return None + def holders( + self, + shortcut_id: ShortcutId, + combinations: Keys, + ) -> Dict[KeyCombination, ShortcutId]: + """Each of ``combinations`` another action of the category holds, with the action holding it.""" + holders: Dict[KeyCombination, ShortcutId] = {} + for combination in combinations: + claimant = self.claimant(shortcut_id, combination) + if claimant is not None: + holders[combination] = claimant + + return holders + def assign( self, shortcut_id: ShortcutId, - combination: KeyCombination, + combinations: Keys, ) -> ShortcutDraft: - """The draft with an action answering ``combination``, taken from whichever action holds it. + """The draft with an action answering exactly ``combinations``, taken from whichever action + holds them. - Leaving the holder unbound in the same step is what keeps every scheme a draft produces - valid, since one combination reaches one action within a category. An edit is held to the - keys the table names, which is what lets every draft be written down and read back. + A holder gives up the keys taken and keeps the rest, its next key becoming its main key, + which keeps every scheme a draft produces valid, since one combination reaches one action + within a category. An edit is held to the keys the table names, which is what lets every + draft be written down and read back. + + Args: + shortcut_id: The action given the keys. + combinations: Its keys, main key first; a repeated key counts once, and an empty list + leaves it unbound. Raises: - KeyError: when the combination is built on a key the table names none of. + KeyError: when a combination is built on a key the table names none of. """ - if not combination.is_writable: - raise KeyError(f"The key {combination.key} carries no name a binding is written under") + given = tuple(dict.fromkeys(combinations)) + for combination in given: + if not combination.is_writable: + raise KeyError(f"The key {combination.key} carries no name a binding is written under") - claimant = self.claimant(shortcut_id, combination) - edits: Dict[ShortcutId, Optional[KeyCombination]] = { - **self.edits, - shortcut_id: combination, - } - if claimant is not None: - edits[claimant] = None + edits: Dict[ShortcutId, Keys] = {**self.edits, shortcut_id: given} + for holder in dict.fromkeys(self.holders(shortcut_id, given).values()): + edits[holder] = tuple(key for key in self.keys(holder) if key not in given) return replace(self, edits=edits) def clear(self, shortcut_id: ShortcutId) -> ShortcutDraft: - """The draft with an action left unbound, its keys free for another action to take.""" - return replace(self, edits={**self.edits, shortcut_id: None}) + """The draft with an action left unbound, every key it held free for another action to take.""" + return self.assign(shortcut_id, ()) def reset(self) -> ShortcutDraft: """The draft with every action back on the keys the scheme ships.""" @@ -137,20 +175,9 @@ def scheme(self) -> ShortcutScheme: return self.base.with_bindings(self.edits) def overrides(self) -> Dict[str, Optional[str]]: - """The edits as a stored preference writes them, keyed by each action's name.""" + """The edits as a stored preference writes them: each action's keys joined by commas, keyed by + the action's name, and ``None`` for an action left unbound. + """ return { - shortcut_id.value: None if combination is None else combination.display() - for shortcut_id, combination in self.edits.items() + shortcut_id.value: display_combinations(keys) if keys else None for shortcut_id, keys in self.edits.items() } - - def _claimed(self, shortcut_id: ShortcutId) -> Tuple[KeyCombination, ...]: - """Every combination an action answers to as the draft stands. - - An action the reader touched answers the one combination they gave it, while the rest answer - the aliases the scheme ships beside their combination, which an assignment has to take too. - """ - if shortcut_id not in self.edits: - return self.base.shortcut(shortcut_id).combinations() - - combination = self.edits[shortcut_id] - return () if combination is None else (combination,) diff --git a/src/sampletones_application/utils/gui/shortcuts/scheme.py b/src/sampletones_application/utils/gui/shortcuts/scheme.py index 38f0d439b..04a5f4720 100644 --- a/src/sampletones_application/utils/gui/shortcuts/scheme.py +++ b/src/sampletones_application/utils/gui/shortcuts/scheme.py @@ -2,11 +2,11 @@ from functools import cached_property from pathlib import Path -from typing import Dict, List, Mapping, Optional, Self +from typing import Dict, List, Mapping, Optional, Self, Tuple from pydantic import BaseModel, model_validator -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import KeyCombination, parse_combinations from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.shortcuts.ids import ( SHORTCUT_IDS_BY_NAME, @@ -118,35 +118,36 @@ def action( def with_binding( self, shortcut_id: ShortcutId, - combination: Optional[KeyCombination], + combinations: Tuple[KeyCombination, ...], ) -> ShortcutScheme: - """The scheme with one action answering ``combination``, as it stands for every other entry. + """The scheme with one action answering ``combinations``, as it stands for every other entry. Args: shortcut_id: The action being given keys. - combination: The keys it answers to, ``None`` leaving it unbound. + combinations: The keys it answers to, main key first; an empty list leaves it unbound. Returns: ShortcutScheme: The scheme every action resolves against once the binding is read. Raises: - SystemError: when another action of the same category already answers the combination. - KeyError: when the combination names a key the key table holds none of. + SystemError: when another action of the same category already answers one of the keys. + KeyError: when a combination names a key the key table holds none of. """ - return self.with_bindings({shortcut_id: combination}) + return self.with_bindings({shortcut_id: combinations}) def with_bindings( self, - combinations: Mapping[ShortcutId, Optional[KeyCombination]], + bindings: Mapping[ShortcutId, Tuple[KeyCombination, ...]], ) -> ShortcutScheme: - """The scheme as the named actions answer the combinations given, read in one step. + """The scheme as the named actions answer the keys given, read in one step. - A named action answers the combination stated and that alone, so the aliases the scheme - shipped it with go with the keys they extended. Reading the whole set at once is what lets - two actions trade combinations, each arriving at keys the other is leaving. + A named action answers the list stated: its first key is the one the action displays, and + the rest answer beside it. Reading the whole set at once is what lets two actions trade + keys, each arriving at keys the other is leaving. Args: - combinations: The keys each named action answers to, ``None`` leaving an action unbound. + bindings: The keys each named action answers to, main key first; an empty list leaves an + action unbound. Returns: ShortcutScheme: The scheme every action resolves against once the bindings are read. @@ -157,9 +158,9 @@ def with_bindings( """ entries: Dict[ShortcutId, WrittenShortcut] = { shortcut_id: self.bindings[shortcut_id].rebound( - None if combination is None else combination.display(), + tuple(combination.display() for combination in combinations), ) - for shortcut_id, combination in combinations.items() + for shortcut_id, combinations in bindings.items() } return ShortcutScheme( @@ -174,12 +175,13 @@ def with_overrides( """The scheme as a reader rebound it, each entry giving one action the keys it names. An override names its action the way a keybinding file writes it, which lets a preference - outlive the build that stored it. The set is read at once, so entries that pass combinations - between them arrive together; where the whole leaves the scheme unresolvable, the entries are - read one at a time and each that stands aside costs only itself. + outlive the build that stored it, and lists the action's keys joined by commas. The set is + read at once, so entries that pass keys between them arrive together; where the whole leaves + the scheme unresolvable, the entries are read one at a time and each that stands aside costs + only itself. Args: - overrides: The combination each rebound action answers to, keyed by the action's name. + overrides: The keys each rebound action answers to, keyed by the action's name. Returns: ShortcutScheme: The scheme every action resolves against once the overrides are read. @@ -193,16 +195,17 @@ def with_overrides( logger.warning(f"Keybindings overrides read one entry at a time: {exception}") return self._rebound_each(overrides) - def rebound(self, name: str, combination: Optional[str]) -> ShortcutScheme: + def rebound(self, name: str, keys: Optional[str]) -> ShortcutScheme: """The scheme as one stored preference rebinds it, read the way a preference is read. - An entry takes effect while it names an action this build carries, a key the table holds and - a combination its category has room for; anything else is reported and the scheme is - returned as it stands, so one unreadable preference costs only itself. + An entry takes effect while it names an action this build carries, keys the table holds and + combinations its category has room for. Anything else is reported and the scheme is + returned as it stands, so one unreadable preference costs only itself, and its action keeps + every key the scheme gives it. Args: name: The action the entry rebinds, named the way a keybinding file writes it. - combination: The keys it answers to, ``None`` leaving the action unbound. + keys: The keys it answers to, joined by commas; ``None`` leaves the action unbound. Returns: ShortcutScheme: The scheme the entry leaves in place. @@ -213,12 +216,9 @@ def rebound(self, name: str, combination: Optional[str]) -> ShortcutScheme: return self try: - return self.with_binding( - shortcut_id, - None if combination is None else KeyCombination.parse(combination), - ) + return self.with_binding(shortcut_id, self._read_keys(keys)) except (KeyError, SystemError) as exception: - logger.warning(f"Keybinding override giving {name!r} the combination {combination!r} left out: {exception}") + logger.warning(f"Keybinding override giving {name!r} the keys {keys!r} left out: {exception}") return self @classmethod @@ -242,23 +242,29 @@ def load(cls, path: Path) -> ShortcutScheme: def _read_overrides( self, overrides: Mapping[str, Optional[str]], - ) -> Dict[ShortcutId, Optional[KeyCombination]]: - """Every override as the action and the combination it names. + ) -> Dict[ShortcutId, Tuple[KeyCombination, ...]]: + """Every override as the action and the keys it names. Raises: KeyError: when an entry names an action this build carries none of, or a key the table holds none of. """ - return { - SHORTCUT_IDS_BY_NAME[name]: None if combination is None else KeyCombination.parse(combination) - for name, combination in overrides.items() - } + return {SHORTCUT_IDS_BY_NAME[name]: self._read_keys(keys) for name, keys in overrides.items()} + + @staticmethod + def _read_keys(keys: Optional[str]) -> Tuple[KeyCombination, ...]: + """The keys a stored override lists, none for an override stating ``None``. + + Raises: + KeyError: when a listed combination names a key the table holds none of. + """ + return () if keys is None else parse_combinations(keys) def _rebound_each(self, overrides: Mapping[str, Optional[str]]) -> ShortcutScheme: """The scheme as every override that stands rebinds it, read one entry at a time.""" scheme = self - for name, combination in overrides.items(): - scheme = scheme.rebound(name, combination) + for name, keys in overrides.items(): + scheme = scheme.rebound(name, keys) return scheme diff --git a/src/sampletones_application/utils/gui/shortcuts/written.py b/src/sampletones_application/utils/gui/shortcuts/written.py index 035b2a8ff..af9f7f6c5 100644 --- a/src/sampletones_application/utils/gui/shortcuts/written.py +++ b/src/sampletones_application/utils/gui/shortcuts/written.py @@ -23,17 +23,20 @@ class WrittenShortcut(BaseModel, frozen=True): aliases: Tuple[str, ...] = NO_WRITTEN_ALIASES field_transparent: bool = False - def rebound(self, combination: Optional[str]) -> WrittenShortcut: - """The entry as a reader rebound it, answering the combination they named and that alone. + def rebound(self, combinations: Tuple[str, ...]) -> WrittenShortcut: + """The entry as a reader rebound it, answering the keys they named, the first one displayed. - The reader states one combination, which is the whole of what reaches the action; the field - transparency stays, since it follows from the action's role rather than from its keys. + The reader states the whole list of keys that reach the action: the first is the one menus + print and the rest answer beside it. The field transparency stays, since it follows from the + action's role. Args: - combination: The keys the action answers to, ``None`` leaving it unbound. + combinations: The keys the action answers to, main key first; an empty list leaves it + unbound. """ return WrittenShortcut( - combination=combination, + combination=combinations[0] if combinations else None, + aliases=combinations[1:], field_transparent=self.field_transparent, ) diff --git a/src/sampletones_application/view_model/shared/keybindings.py b/src/sampletones_application/view_model/shared/keybindings.py index 1d0d68954..34fd1d461 100644 --- a/src/sampletones_application/view_model/shared/keybindings.py +++ b/src/sampletones_application/view_model/shared/keybindings.py @@ -9,13 +9,13 @@ class KeybindingRow(BaseModel, frozen=True): """One action as the keybindings dialog lists it: its name, its label, and the keys it answers. An action travels under the name a keybinding file writes it by, which is the identity a stored - preference is keyed by as well, so a row states which action it stands for without the view - reaching into the shortcut vocabulary. + preference is keyed by as well, so a row states which action it stands for in the terms the view + reads. Its keys read as a list joined by commas, main key first, and empty while it is unbound. """ action: str label: str - combination: str + keys: str def matches(self, text: str) -> bool: """Whether the row answers a filter, which reads both what it is called and what it answers. @@ -24,10 +24,10 @@ def matches(self, text: str) -> bool: text: What the reader typed, matched in any capitalization. Returns: - bool: True while the label or the combination holds the text, and for an empty filter. + bool: True while the label or the keys hold the text, and for an empty filter. """ wanted = text.strip().casefold() - return wanted in self.label.casefold() or wanted in self.combination.casefold() + return wanted in self.label.casefold() or wanted in self.keys.casefold() class KeybindingGroup(BaseModel, frozen=True): @@ -46,13 +46,14 @@ class KeybindingGroup(BaseModel, frozen=True): class KeybindingsViewModel(BaseModel, frozen=True): """What the keybindings dialog draws: the actions listed, the selection standing, and its state. - The dialog edits a draft the owner holds, so what shows here is the draft rather than the keys - the application is running under; the two meet when the reader confirms. + The dialog edits a draft the owner holds, so what shows here is the draft. It reaches the keys + the application runs under when the reader confirms. ``keys`` is what the entry box shows: the + selected action's keys joined by commas. """ groups: Tuple[KeybindingGroup, ...] schemes: Tuple[str, ...] scheme: str selected: Optional[str] - combination: str + keys: str message: str diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 93504bd5e..eb5b85d40 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -942,6 +942,8 @@ settings.keybindings.message.capturing: "Press a combination..." settings.keybindings.message.reset_confirmation: "Give every action the keys this scheme ships with?" settings.keybindings.message.discard_confirmation: "Discard the changes to the keyboard shortcuts?" settings.keybindings.template.reassign_confirmation: "{combination} is assigned to {holder}. Give it to {action} instead?" +settings.keybindings.template.reassign_several_confirmation: "These keys are assigned to other actions:\n{assignments}\nGive them to {action} instead?" +settings.keybindings.template.assignment: "{combination}: {holder}" settings.keybindings.template.unreadable_combination: "{combination} names no key on the keyboard." settings.keybindings.label.new_project: "New project" settings.keybindings.label.open_project: "Open project" diff --git a/tests/screens/interface/keybindings/test_every_key.py b/tests/screens/interface/keybindings/test_every_key.py new file mode 100644 index 000000000..703c67c5a --- /dev/null +++ b/tests/screens/interface/keybindings/test_every_key.py @@ -0,0 +1,102 @@ +import operator +from functools import partial +from typing import Final, List + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_core.constants.enums import ChannelName +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.keyboard import press_combination +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import channels_sounding, sounding_but +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT +from tests.suite.screens.written import written_application_config + +TOGGLE_PULSE_TWO: Final[ShortcutId] = ShortcutId.TOGGLE_CHANNEL_PULSE_2 +NEW_MAIN_KEY: Final[KeyCombination] = KeyCombination.parse("Ctrl+Shift+2") +KEY_LIST_SEPARATOR: Final[str] = ", " +SETTLING_FRAMES: Final[int] = 20 + + +@pytest.fixture +def startup() -> Startup: + """Opens the application with the arranged project and no reconstruction.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +def written_out(combinations: List[KeyCombination]) -> str: + return KEY_LIST_SEPARATOR.join(combination.display() for combination in combinations) + + +class TestEditingARowKeepsEveryKey: + """A channel's row lists its main key and its chord, and a new main key keeps both. + + The Pulse 2 row shows every key the scheme gives it. A pressed key leads the list and the earlier keys + follow it. After OK the chord still mutes Pulse 2, the new key brings it back, and leaving writes every + key down. + """ + + def test_the_row_keeps_its_second_key(self, screen: Screen) -> None: + settings = screen.keyboard_settings + shipped: List[KeyCombination] = [] + + def the_row_shows_every_key(screen: Screen) -> None: + shipped.extend(screen.shortcut(TOGGLE_PULSE_TWO).combinations()) + settings.open() + screen.expect(settings.is_shown, bool, description="Keyboard settings") + + assert len(shipped) > 1 + assert settings.keys_of(TOGGLE_PULSE_TWO) == written_out(shipped) + + def a_pressed_key_leads_the_others(screen: Screen) -> None: + settings.listen_for(TOGGLE_PULSE_TWO) + + press_combination(screen.hand, NEW_MAIN_KEY) + + screen.expect( + partial(settings.keys_of, TOGGLE_PULSE_TWO), + written_out([NEW_MAIN_KEY, *shipped]).__eq__, + description="the new key ahead of the others", + ) + settings.confirm() + screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") + + def the_chord_still_mutes_pulse_two(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.frames(SETTLING_FRAMES) + + press_combination(screen.hand, shipped[-1]) + + screen.expect( + partial(channels_sounding, screen), + sounding_but(ChannelName.PULSE2).__eq__, + description="Pulse 2 muted", + ) + + def the_new_key_brings_it_back(screen: Screen) -> None: + press_combination(screen.hand, NEW_MAIN_KEY) + + screen.expect( + partial(channels_sounding, screen), + sounding_but().__eq__, + description="every channel sounding again", + ) + + def leaving_writes_every_key(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.EXIT) + + assert screen.wait_for_exit() + assert written_application_config().shortcuts.overrides == { + TOGGLE_PULSE_TWO.value: written_out([NEW_MAIN_KEY, *shipped]), + } + + screen.scenario( + the_row_shows_every_key, + a_pressed_key_leads_the_others, + the_chord_still_mutes_pulse_two, + the_new_key_brings_it_back, + leaving_writes_every_key, + ).run() diff --git a/tests/screens/interface/keybindings/test_rebinding_undo.py b/tests/screens/interface/keybindings/test_rebinding_undo.py index 402841070..29d0ca5ba 100644 --- a/tests/screens/interface/keybindings/test_rebinding_undo.py +++ b/tests/screens/interface/keybindings/test_rebinding_undo.py @@ -7,7 +7,7 @@ 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.keyboard.combination import KeyCombination, parse_combinations from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from tests.suite.screens.application.startup import Startup from tests.suite.screens.dearpygui.items.reading import read_item @@ -21,6 +21,7 @@ NEW_UNDO: Final[KeyCombination] = KeyCombination.parse("Ctrl+Alt+U") RENAMED: Final[str] = "Renamed" SETTLING_FRAMES: Final[int] = 20 +KEY_LIST_SEPARATOR: Final[str] = ", " @pytest.fixture @@ -48,9 +49,11 @@ def rename_the_line(screen: Screen) -> None: class TestRebindingUndo: """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. + Escape cancels a capture, a pressed key leads the keys Undo had, keys written in the entry box become + exactly Undo's keys, keys another action holds ask before they are taken, and leaving writes the rebind. + The scenario presses Ctrl+Alt+U on Undo's row, writes it alone in the entry box, renames a voice, tries + the old keys and then the new ones, tries to take Redo's main key and cancels, and leaves the + application. """ def test_the_new_keys_undo(self, screen: Screen) -> None: @@ -83,10 +86,17 @@ def rebind_undo(screen: Screen) -> None: press_combination(screen.hand, NEW_UNDO) + screen.expect( + partial(settings.keys_of, ShortcutId.UNDO), + f"{NEW_UNDO.display()}{KEY_LIST_SEPARATOR}{original[ShortcutId.UNDO]}".__eq__, + description="the new keys ahead of the old ones", + ) + settings.write_keys(ShortcutId.UNDO, NEW_UNDO.display()) + screen.expect( partial(settings.keys_of, ShortcutId.UNDO), NEW_UNDO.display().__eq__, - description="the new keys", + description="the new keys alone", ) settings.confirm() screen.expect(settings.is_shown, operator.not_, description="Keyboard settings closed") @@ -100,7 +110,7 @@ def rebind_undo(screen: Screen) -> None: 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])) + press_combination(screen.hand, parse_combinations(original[ShortcutId.UNDO])[0]) screen.frames(SETTLING_FRAMES) assert voices.names() == [RENAMED, BASS_VOICE, PAD] @@ -113,7 +123,7 @@ def keys_another_action_holds_ask_first(screen: Screen) -> None: screen.expect(settings.is_shown, bool, description="Keyboard settings") settings.listen_for(ShortcutId.UNDO) - press_combination(screen.hand, KeyCombination.parse(original[ShortcutId.REDO])) + press_combination(screen.hand, parse_combinations(original[ShortcutId.REDO])[0]) screen.expect(reassign.is_shown, bool, description="the question about reassigning") reassign.cancel() diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 3711012af..0517e9b7d 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -19,6 +19,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_application.utils.gui.shortcuts.shortcut import Shortcut from sampletones_core.constants.enums import ChannelName, GeneratorName from tests.suite.scenario import BaseTestScenario, ScenarioStep from tests.suite.screens.boundaries.audio import OutputRecord @@ -136,6 +137,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 shortcut(self, shortcut_id: ShortcutId) -> Shortcut: + """The binding the scheme in place gives ``shortcut_id``: its main key and the further ones.""" + return self._shortcuts.shortcut(shortcut_id) + 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() diff --git a/tests/suite/screens/views/keyboard_settings.py b/tests/suite/screens/views/keyboard_settings.py index 077fa013d..1a284f3b4 100644 --- a/tests/suite/screens/views/keyboard_settings.py +++ b/tests/suite/screens/views/keyboard_settings.py @@ -3,10 +3,12 @@ from sampletones_application.tags.general import SUF_BUTTON from sampletones_application.tags.settings import ( PRE_SETTINGS_KEYBINDINGS_ROW, + SUF_SETTINGS_KEYBINDINGS_ACTION, SUF_SETTINGS_KEYBINDINGS_SHORTCUT, TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, TAG_SETTINGS_KEYBINDINGS_BUTTON_OK, TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN, + TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT, TAG_SETTINGS_KEYBINDINGS_WINDOW, ) from sampletones_application.utils.gui.shortcuts.ids import ShortcutId @@ -14,6 +16,7 @@ from tests.suite.screens.dearpygui.hand import Hand 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.keys import IMGUI_ENTER from tests.suite.screens.views.menus import MenuBar from tests.suite.screens.views.prompts import Prompt @@ -46,6 +49,16 @@ def listen_for(self, shortcut_id: ShortcutId) -> None: self._hand.scroll_into_view(cell) self._hand.click(cell) + def write_keys(self, shortcut_id: ShortcutId, text: str) -> None: + """Picks the row of ``shortcut_id`` by its name, writes ``text`` over the keys in the entry box + and presses Enter, which gives the action exactly the keys written. + """ + action = _action_cell(shortcut_id) + self._hand.scroll_into_view(action) + self._hand.click(action) + self._hand.replace_text(TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT, text) + self._hand.press_key(IMGUI_ENTER, modifiers=[]) + 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))) @@ -61,3 +74,7 @@ def confirm(self) -> None: def _shortcut_cell(shortcut_id: ShortcutId) -> str: return compose_tag(PRE_SETTINGS_KEYBINDINGS_ROW, shortcut_id.value, SUF_SETTINGS_KEYBINDINGS_SHORTCUT) + + +def _action_cell(shortcut_id: ShortcutId) -> str: + return compose_tag(PRE_SETTINGS_KEYBINDINGS_ROW, shortcut_id.value, SUF_SETTINGS_KEYBINDINGS_ACTION) diff --git a/tests/suite/shortcuts.py b/tests/suite/shortcuts.py index b63afe3ad..99f69887f 100644 --- a/tests/suite/shortcuts.py +++ b/tests/suite/shortcuts.py @@ -36,5 +36,5 @@ def rebound_source(shortcut_id: ShortcutId, combination: str) -> ShortcutSource: """ scheme = shipped_scheme() rebound = dict(scheme.bindings) - rebound[shortcut_id] = rebound[shortcut_id].rebound(combination) + rebound[shortcut_id] = rebound[shortcut_id].rebound((combination,)) return ShortcutSource(ShortcutScheme(name=scheme.name, bindings=rebound)) diff --git a/tests/unit/sampletones_application/coordinators/test_keybindings.py b/tests/unit/sampletones_application/coordinators/test_keybindings.py index ea4eb89c8..d1b037b3f 100644 --- a/tests/unit/sampletones_application/coordinators/test_keybindings.py +++ b/tests/unit/sampletones_application/coordinators/test_keybindings.py @@ -28,6 +28,9 @@ REDO_COMBINATION: Final[str] = "Ctrl+Y" FREE_COMBINATION: Final[str] = "Ctrl+Alt+B" UNREADABLE_COMBINATION: Final[str] = "Ctrl+Nonsense" +FREE_KEYS: Final[str] = "Ctrl+Alt+B, Ctrl+Alt+N" +TWO_HOLDERS: Final[Tuple[ShortcutId, ...]] = (ShortcutId.SAVE_PROJECT, ShortcutId.OPEN_PROJECT) +TWO_HOLDER_LABELS: Final[Tuple[str, ...]] = ("Save project", "Open project") class _SessionRecorder: @@ -173,10 +176,10 @@ def harness_fixture() -> Harness: class TestOpening: def test_the_dialog_shows_the_keys_in_force(self, harness: Harness) -> None: - assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION def test_an_unbound_action_is_listed_carrying_no_keys(self, harness: Harness) -> None: - assert harness.row(ABOUT_DIALOG).combination == "" + assert harness.row(ABOUT_DIALOG).keys == "" def test_every_editable_scope_is_listed(self, harness: Harness) -> None: assert tuple(group.category for group in harness.window.view_model.groups) == tuple( @@ -204,7 +207,7 @@ def test_the_stored_preference_reaches_the_dialog(self) -> None: harness.session.shortcut_overrides = {SAVE_PROJECT: FREE_COMBINATION} harness.open() - assert harness.row(SAVE_PROJECT).combination == FREE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == FREE_COMBINATION class TestSelection: @@ -212,12 +215,12 @@ def test_selecting_an_action_puts_its_keys_in_the_entry_box(self, harness: Harne harness.select(SAVE_PROJECT) assert harness.window.view_model.selected == SAVE_PROJECT - assert harness.window.view_model.combination == SAVE_COMBINATION + assert harness.window.view_model.keys == SAVE_COMBINATION def test_an_unbound_action_leaves_the_entry_box_empty(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) - assert harness.window.view_model.combination == "" + assert harness.window.view_model.keys == "" def test_a_combination_arriving_with_nothing_selected_is_refused(self, harness: Harness) -> None: with pytest.raises(SystemError): @@ -229,26 +232,26 @@ def test_a_written_combination_reaches_the_action(self, harness: Harness) -> Non harness.select(ABOUT_DIALOG) harness.type_combination(FREE_COMBINATION) - assert harness.row(ABOUT_DIALOG).combination == FREE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == FREE_COMBINATION def test_a_written_combination_reads_back_the_way_it_is_displayed(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) harness.type_combination("shift+ctrl+alt+b") - assert harness.row(ABOUT_DIALOG).combination == "Ctrl+Alt+Shift+B" + assert harness.row(ABOUT_DIALOG).keys == "Ctrl+Alt+Shift+B" def test_a_captured_press_reaches_the_action(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) harness.capture(FREE_COMBINATION) - assert harness.row(ABOUT_DIALOG).combination == FREE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == FREE_COMBINATION def test_a_combination_naming_no_key_is_reported_and_the_keys_stand(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) harness.type_combination(UNREADABLE_COMBINATION) assert UNREADABLE_COMBINATION in harness.window.view_model.message - assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION def test_a_later_assignment_clears_the_message(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) @@ -271,7 +274,7 @@ def test_assigning_keys_another_action_holds_asks_first(self, harness: Harness) harness.type_combination(SAVE_COMBINATION) assert len(harness.dialogs.confirmations) == 1 - assert harness.row(ABOUT_DIALOG).combination == "" + assert harness.row(ABOUT_DIALOG).keys == "" def test_the_prompt_names_the_action_the_keys_are_taken_from(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) @@ -290,8 +293,8 @@ def test_confirming_takes_the_keys_and_leaves_the_holder_unbound(self, harness: harness.type_combination(SAVE_COMBINATION) harness.dialogs.confirm() - assert harness.row(ABOUT_DIALOG).combination == SAVE_COMBINATION - assert harness.row(SAVE_PROJECT).combination == "" + assert harness.row(ABOUT_DIALOG).keys == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == "" assert harness.window.visible def test_declining_leaves_both_actions_on_the_keys_they_had(self, harness: Harness) -> None: @@ -299,8 +302,8 @@ def test_declining_leaves_both_actions_on_the_keys_they_had(self, harness: Harne harness.type_combination(SAVE_COMBINATION) harness.dialogs.decline() - assert harness.row(ABOUT_DIALOG).combination == "" - assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == "" + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION assert harness.window.visible def test_declining_a_pressed_combination_brings_its_row_back_listening(self, harness: Harness) -> None: @@ -311,7 +314,7 @@ def test_declining_a_pressed_combination_brings_its_row_back_listening(self, har assert harness.window.visible assert harness.window.listening - assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION def test_declining_a_written_combination_brings_the_dialog_back_listening_for_nothing( self, @@ -334,23 +337,24 @@ def test_confirming_a_pressed_combination_brings_the_dialog_back_listening_for_n assert harness.window.visible assert not harness.window.listening - assert harness.row(ABOUT_DIALOG).combination == SAVE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == SAVE_COMBINATION - def test_an_alias_another_action_answers_is_taken_the_same_way(self, harness: Harness) -> None: - """Redo answers Ctrl+Shift+Z beside its own keys, which an assignment takes with them.""" + def test_an_alias_another_action_answers_is_taken_alone(self, harness: Harness) -> None: + """Redo answers an alias beside its main key, and taking the alias leaves Redo its main key.""" + redo = shipped_scheme().shortcut(ShortcutId.REDO) harness.select(ABOUT_DIALOG) - harness.type_combination("Ctrl+Shift+Z") + harness.type_combination(redo.aliases[0].display()) harness.dialogs.confirm() - assert harness.row(ABOUT_DIALOG).combination == "Ctrl+Shift+Z" - assert harness.row(ShortcutId.REDO.value).combination == "" + assert harness.row(ABOUT_DIALOG).keys == redo.aliases[0].display() + assert harness.row(REDO).keys == redo.display() def test_the_keys_an_action_already_answers_are_assigned_without_asking(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) harness.type_combination(SAVE_COMBINATION) assert harness.dialogs.confirmations == [] - assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION class TestClear: @@ -358,7 +362,7 @@ def test_clearing_leaves_the_action_unbound(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) harness.clear() - assert harness.row(SAVE_PROJECT).combination == "" + assert harness.row(SAVE_PROJECT).keys == "" def test_the_keys_a_cleared_action_held_are_free_to_take(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) @@ -367,7 +371,7 @@ def test_the_keys_a_cleared_action_held_are_free_to_take(self, harness: Harness) harness.type_combination(SAVE_COMBINATION) assert harness.dialogs.confirmations == [] - assert harness.row(ABOUT_DIALOG).combination == SAVE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == SAVE_COMBINATION class TestReset: @@ -385,7 +389,7 @@ def test_confirming_puts_every_action_back_on_the_shipped_keys(self, harness: Ha harness.reset() harness.dialogs.confirm() - assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION assert harness.window.visible def test_declining_leaves_the_edits_standing(self, harness: Harness) -> None: @@ -394,7 +398,7 @@ def test_declining_leaves_the_edits_standing(self, harness: Harness) -> None: harness.reset() harness.dialogs.decline() - assert harness.row(SAVE_PROJECT).combination == FREE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == FREE_COMBINATION def test_a_reset_stores_no_overrides(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) @@ -449,8 +453,8 @@ def test_a_stored_preference_reopens_on_the_keys_it_stored(self, harness: Harnes harness.commit() harness.open() - assert harness.row(ABOUT_DIALOG).combination == SAVE_COMBINATION - assert harness.row(SAVE_PROJECT).combination == "" + assert harness.row(ABOUT_DIALOG).keys == SAVE_COMBINATION + assert harness.row(SAVE_PROJECT).keys == "" class TestCancel: @@ -475,7 +479,7 @@ def test_keeping_the_edit_brings_the_dialog_back(self, harness: Harness) -> None harness.dialogs.decline() assert harness.window.visible - assert harness.row(ABOUT_DIALOG).combination == FREE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == FREE_COMBINATION def test_discarding_leaves_the_keys_in_force_alone(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) @@ -501,7 +505,7 @@ def test_choosing_the_scheme_already_open_leaves_the_edits_standing(self, harnes harness.type_combination(FREE_COMBINATION) harness.select_scheme(shipped_scheme().name) - assert harness.row(ABOUT_DIALOG).combination == FREE_COMBINATION + assert harness.row(ABOUT_DIALOG).keys == FREE_COMBINATION def test_an_unknown_scheme_falls_back_to_the_one_the_build_defaults_to(self, harness: Harness) -> None: harness.select_scheme("nonexistent") @@ -522,8 +526,8 @@ def traded_fixture(self, harness: Harness) -> Harness: return harness def test_each_action_arrives_at_the_keys_the_other_left(self, traded: Harness) -> None: - assert traded.row(UNDO).combination == REDO_COMBINATION - assert traded.row(REDO).combination == UNDO_COMBINATION + assert traded.row(UNDO).keys == REDO_COMBINATION + assert traded.row(REDO).keys == UNDO_COMBINATION def test_the_traded_keys_reach_the_scheme_put_in_force(self, traded: Harness) -> None: traded.commit() @@ -535,5 +539,73 @@ def test_a_stored_trade_reopens_on_the_keys_it_stored(self, traded: Harness) -> traded.commit() traded.open() - assert traded.row(UNDO).combination == REDO_COMBINATION - assert traded.row(REDO).combination == UNDO_COMBINATION + assert traded.row(UNDO).keys == REDO_COMBINATION + assert traded.row(REDO).keys == UNDO_COMBINATION + + +class TestEveryKey: + """An action answers a list of keys, its main key first, and the editor keeps every one of them.""" + + def test_a_typed_list_becomes_every_key_of_the_action(self, harness: Harness) -> None: + harness.select(ABOUT_DIALOG) + harness.type_combination(FREE_KEYS) + + assert harness.row(ABOUT_DIALOG).keys == FREE_KEYS + + def test_a_pressed_key_leads_and_the_other_keys_stay(self, harness: Harness) -> None: + redo = shipped_scheme().shortcut(ShortcutId.REDO) + harness.select(REDO) + harness.capture(FREE_COMBINATION) + + assert harness.row(REDO).keys == ", ".join( + (FREE_COMBINATION, *(combination.display() for combination in redo.combinations())) + ) + + def test_pressing_an_own_alias_moves_it_to_the_front_without_asking(self, harness: Harness) -> None: + redo = shipped_scheme().shortcut(ShortcutId.REDO) + harness.select(REDO) + harness.capture(redo.aliases[0].display()) + + assert harness.dialogs.confirmations == [] + assert harness.row(REDO).keys == ", ".join( + combination.display() for combination in (*redo.aliases, redo.combinations()[0]) + ) + + def test_a_typed_list_taking_keys_from_two_actions_asks_once_naming_both(self, harness: Harness) -> None: + scheme = shipped_scheme() + taken = ", ".join(scheme.shortcut(shortcut_id).display() for shortcut_id in TWO_HOLDERS) + harness.select(ABOUT_DIALOG) + harness.type_combination(taken) + + assert len(harness.dialogs.confirmations) == 1 + assert all(label in harness.dialogs.confirmations[-1]["message"] for label in TWO_HOLDER_LABELS) + + def test_confirming_a_list_taken_from_two_actions_moves_both_keys(self, harness: Harness) -> None: + scheme = shipped_scheme() + taken = ", ".join(scheme.shortcut(shortcut_id).display() for shortcut_id in TWO_HOLDERS) + harness.select(ABOUT_DIALOG) + harness.type_combination(taken) + harness.dialogs.confirm() + + assert harness.row(ABOUT_DIALOG).keys == taken + assert all(harness.row(shortcut_id.value).keys == "" for shortcut_id in TWO_HOLDERS) + + def test_clearing_unbinds_every_key(self, harness: Harness) -> None: + harness.select(REDO) + harness.clear() + + assert harness.row(REDO).keys == "" + + def test_a_list_is_stored_as_written(self, harness: Harness) -> None: + harness.select(ABOUT_DIALOG) + harness.type_combination(FREE_KEYS) + harness.commit() + + assert dict(harness.session.writes)["overrides"] == {ABOUT_DIALOG: FREE_KEYS} + + def test_a_list_naming_no_key_in_one_place_is_reported_and_the_keys_stand(self, harness: Harness) -> None: + harness.select(SAVE_PROJECT) + harness.type_combination(f"{FREE_COMBINATION}, {UNREADABLE_COMBINATION}") + + assert UNREADABLE_COMBINATION in harness.window.view_model.message + assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py index 4486f714c..c04bb2a5a 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py @@ -62,7 +62,7 @@ def group_tag(category: str) -> str: def view_model( *, selected: Optional[str] = None, - combination: str = "", + keys: str = "", message: str = "", ) -> KeybindingsViewModel: return KeybindingsViewModel( @@ -71,20 +71,20 @@ def view_model( category="application", label="Application", rows=( - KeybindingRow(action=SAVE_PROJECT, label="Save project", combination="Ctrl+S"), - KeybindingRow(action=ABOUT_DIALOG, label="About", combination=""), + KeybindingRow(action=SAVE_PROJECT, label="Save project", keys="Ctrl+S"), + KeybindingRow(action=ABOUT_DIALOG, label="About", keys=""), ), ), KeybindingGroup( category="tracker", label="Tracker", - rows=(KeybindingRow(action=TRACKER_NEXT_ROW, label="Next row", combination="Down"),), + rows=(KeybindingRow(action=TRACKER_NEXT_ROW, label="Next row", keys="Down"),), ), ), schemes=SCHEMES, scheme="default", selected=selected, - combination=combination, + keys=keys, message=message, ) @@ -201,20 +201,20 @@ def test_a_later_view_re_reads_the_rows_already_built(self, harness: Harness) -> category="application", label="Application", rows=( - KeybindingRow(action=SAVE_PROJECT, label="Save project", combination="Ctrl+Alt+B"), - KeybindingRow(action=ABOUT_DIALOG, label="About", combination=""), + KeybindingRow(action=SAVE_PROJECT, label="Save project", keys="Ctrl+Alt+B"), + KeybindingRow(action=ABOUT_DIALOG, label="About", keys=""), ), ), KeybindingGroup( category="tracker", label="Tracker", - rows=(KeybindingRow(action=TRACKER_NEXT_ROW, label="Next row", combination="Down"),), + rows=(KeybindingRow(action=TRACKER_NEXT_ROW, label="Next row", keys="Down"),), ), ), schemes=SCHEMES, scheme="default", selected=None, - combination="", + keys="", message="", ) ) @@ -293,7 +293,7 @@ def test_the_selected_row_reads_as_selected(self, harness: Harness) -> None: assert dpg.get_value(action_tag(ABOUT_DIALOG)) is False def test_the_entry_box_shows_the_selected_action_keys(self, harness: Harness) -> None: - harness.render(view_model(selected=SAVE_PROJECT, combination="Ctrl+S")) + harness.render(view_model(selected=SAVE_PROJECT, keys="Ctrl+S")) assert dpg.get_value(TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT) == "Ctrl+S" diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py index 565893c34..ade66f192 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py @@ -1,10 +1,10 @@ from dataclasses import dataclass -from typing import Dict, Optional +from typing import Dict, Optional, Tuple import dearpygui.dearpygui as dpg import pytest -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import KeyCombination, display_combinations from sampletones_application.utils.gui.keyboard.keys import ( KEY_DISPLAY_NAMES, KEY_MODIFIER_ALT, @@ -18,11 +18,16 @@ from tests.suite.case import BaseRegularTestCase FREE_COMBINATION = "Ctrl+Alt+B" +SECOND_FREE_COMBINATION = "Ctrl+Alt+N" TABLE_COMBINATION = "Del" UNNAMED_KEY = -1 +def keys(*written: str) -> Tuple[KeyCombination, ...]: + return tuple(KeyCombination.parse(combination) for combination in written) + + @pytest.fixture def draft(shipped: ShortcutScheme) -> ShortcutDraft: """A draft of the shipped scheme, opened on a session that stores no preference of its own.""" @@ -31,7 +36,7 @@ def draft(shipped: ShortcutScheme) -> ShortcutDraft: class TestOpen: def test_a_session_storing_nothing_opens_on_the_keys_the_scheme_ships(self, draft: ShortcutDraft) -> None: - assert draft.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Z") + assert draft.keys(ShortcutId.UNDO) == keys("Ctrl+Z") def test_a_draft_opens_on_what_the_session_holds(self, shipped: ShortcutScheme) -> None: """A dialog asks whether the reader changed anything, which counts from the moment it opened.""" @@ -40,7 +45,7 @@ def test_a_draft_opens_on_what_the_session_holds(self, shipped: ShortcutScheme) def test_a_stored_override_opens_as_the_keys_its_action_answers(self, shipped: ShortcutScheme) -> None: draft = ShortcutDraft.open(shipped, {"Undo": "Ctrl+Alt+U"}) - assert draft.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Alt+U") + assert draft.keys(ShortcutId.UNDO) == keys("Ctrl+Alt+U") def test_a_stored_override_reads_back_as_the_preference_it_came_from(self, shipped: ShortcutScheme) -> None: overrides: Dict[str, Optional[str]] = {"Undo": "Ctrl+Alt+U"} @@ -50,13 +55,38 @@ def test_a_stored_override_reads_back_as_the_preference_it_came_from(self, shipp def test_a_stored_override_stating_no_combination_opens_unbound(self, shipped: ShortcutScheme) -> None: draft = ShortcutDraft.open(shipped, {"Undo": None}) - assert draft.combination(ShortcutId.UNDO) is None + assert draft.keys(ShortcutId.UNDO) == () + + def test_a_stored_override_listing_the_main_key_alone_opens_as_an_edit(self, shipped: ShortcutScheme) -> None: + """An override lists every key of its action, so one naming the main key alone leaves out the rest.""" + main = shipped.shortcut(ShortcutId.ORDER_INSERT_FRAME).display() + draft = ShortcutDraft.open(shipped, {"OrderInsertFrame": main}) + + assert draft.overrides() == {"OrderInsertFrame": main} + + def test_a_stored_override_listing_every_shipped_key_opens_as_no_edit(self, shipped: ShortcutScheme) -> None: + every = display_combinations(shipped.shortcut(ShortcutId.ORDER_INSERT_FRAME).combinations()) + draft = ShortcutDraft.open(shipped, {"OrderInsertFrame": every}) + + assert draft.overrides() == {} + + def test_a_stored_single_key_opens_as_an_action_with_that_key_alone(self, shipped: ShortcutScheme) -> None: + """A preference stored as one key per action reads the same way, as a list of one key.""" + draft = ShortcutDraft.open(shipped, {"Redo": FREE_COMBINATION}) + + assert draft.keys(ShortcutId.REDO) == keys(FREE_COMBINATION) - def test_a_stored_override_dropping_the_aliases_alone_opens_as_an_edit(self, shipped: ShortcutScheme) -> None: - """An override states the whole of what reaches an action, so the aliases go with it.""" - draft = ShortcutDraft.open(shipped, {"OrderInsertFrame": "Plus"}) + def test_a_stored_list_opens_as_every_key_it_names(self, shipped: ShortcutScheme) -> None: + draft = ShortcutDraft.open(shipped, {"Redo": f"{FREE_COMBINATION}, {SECOND_FREE_COMBINATION}"}) - assert draft.overrides() == {"OrderInsertFrame": "Plus"} + assert draft.keys(ShortcutId.REDO) == keys(FREE_COMBINATION, SECOND_FREE_COMBINATION) + + def test_a_stored_list_naming_no_key_in_one_place_stays_behind_whole(self, shipped: ShortcutScheme) -> None: + """An override that is unreadable in part costs its action alone, which keeps every shipped key.""" + draft = ShortcutDraft.open(shipped, {"Redo": f"{FREE_COMBINATION}, Ctrl+Nonsense", "Undo": "Ctrl+Alt+U"}) + + assert draft.overrides() == {"Undo": "Ctrl+Alt+U"} + assert draft.keys(ShortcutId.REDO) == shipped.shortcut(ShortcutId.REDO).combinations() def test_a_stored_override_this_build_carries_no_action_for_stays_behind(self, shipped: ShortcutScheme) -> None: """A preference outlives the build that stored it, so a stale entry costs only itself.""" @@ -70,22 +100,58 @@ def test_a_stored_override_its_category_already_answers_stays_behind(self, shipp assert draft.overrides() == {} -class TestCombination: - def test_an_untouched_action_reads_the_keys_the_scheme_gives_it(self, draft: ShortcutDraft) -> None: - assert draft.combination(ShortcutId.SAVE_PROJECT) == KeyCombination.parse("Ctrl+S") +class TestKeys: + def test_an_untouched_action_reads_every_key_the_scheme_gives_it( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + assert draft.keys(ShortcutId.REDO) == shipped.shortcut(ShortcutId.REDO).combinations() def test_an_assigned_action_reads_the_keys_the_reader_gave_it(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)) + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION, SECOND_FREE_COMBINATION)) + + assert edited.keys(ShortcutId.UNDO) == keys(FREE_COMBINATION, SECOND_FREE_COMBINATION) - assert edited.combination(ShortcutId.UNDO) == KeyCombination.parse(FREE_COMBINATION) + def test_a_key_given_twice_counts_once(self, draft: ShortcutDraft) -> None: + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION, FREE_COMBINATION)) + + assert edited.keys(ShortcutId.UNDO) == keys(FREE_COMBINATION) def test_a_cleared_action_reads_as_unbound(self, draft: ShortcutDraft) -> None: - edited = draft.clear(ShortcutId.UNDO) + edited = draft.clear(ShortcutId.REDO) - assert edited.combination(ShortcutId.UNDO) is None + assert edited.keys(ShortcutId.REDO) == () def test_an_action_the_scheme_leaves_unbound_reads_as_unbound(self, draft: ShortcutDraft) -> None: - assert draft.combination(ShortcutId.ABOUT_DIALOG) is None + assert draft.keys(ShortcutId.ABOUT_DIALOG) == () + + +class TestKeysLedBy: + """A pressed key becomes the action's main key, and the keys it had follow it.""" + + def test_a_new_key_leads_the_keys_the_action_had(self, draft: ShortcutDraft, shipped: ShortcutScheme) -> None: + led = draft.keys_led_by(ShortcutId.REDO, KeyCombination.parse(FREE_COMBINATION)) + + assert led == (KeyCombination.parse(FREE_COMBINATION), *shipped.shortcut(ShortcutId.REDO).combinations()) + + def test_an_own_alias_moves_to_the_front_and_nothing_drops( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + redo = shipped.shortcut(ShortcutId.REDO) + + led = draft.keys_led_by(ShortcutId.REDO, redo.aliases[0]) + + assert led[0] == redo.aliases[0] + assert set(led) == set(redo.combinations()) + assert len(led) == len(redo.combinations()) + + def test_an_own_alias_asks_no_one(self, draft: ShortcutDraft, shipped: ShortcutScheme) -> None: + redo = shipped.shortcut(ShortcutId.REDO) + + assert draft.holders(ShortcutId.REDO, draft.keys_led_by(ShortcutId.REDO, redo.aliases[0])) == {} class TestClaimant: @@ -95,7 +161,7 @@ def test_the_action_holding_a_combination_answers_for_it(self, draft: ShortcutDr assert claimant is ShortcutId.SAVE_PROJECT def test_an_alias_is_held_as_firmly_as_the_combination_it_extends(self, draft: ShortcutDraft) -> None: - """An assignment takes every key that reaches the holder, aliases included.""" + """An alias is a key its holder answers, so giving it away asks the holder as the main key does.""" claimant = draft.claimant(ShortcutId.ORDER_ADD_FRAME, KeyCombination.parse("NumPlus")) assert claimant is ShortcutId.ORDER_INSERT_FRAME @@ -111,18 +177,29 @@ def test_an_action_holds_its_own_keys_against_no_one(self, draft: ShortcutDraft) assert draft.claimant(ShortcutId.UNDO, KeyCombination.parse("Ctrl+Z")) is None def test_the_keys_an_edit_left_behind_are_free(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)) + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)) assert edited.claimant(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+Z")) is None - def test_the_aliases_an_edit_left_behind_are_free(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.ORDER_INSERT_FRAME, KeyCombination.parse("Ctrl+Alt+I")) + def test_the_aliases_a_written_list_leaves_out_are_free(self, draft: ShortcutDraft) -> None: + edited = draft.assign(ShortcutId.ORDER_INSERT_FRAME, keys("Ctrl+Alt+I")) assert edited.claimant(ShortcutId.ORDER_ADD_FRAME, KeyCombination.parse("NumPlus")) is None + def test_the_aliases_a_pressed_key_leads_stay_held(self, draft: ShortcutDraft) -> None: + """A pressed key joins the keys an action had, so its aliases still answer it.""" + led = draft.keys_led_by(ShortcutId.ORDER_INSERT_FRAME, KeyCombination.parse("Ctrl+Alt+I")) + edited = draft.assign(ShortcutId.ORDER_INSERT_FRAME, led) + + assert edited.claimant(ShortcutId.ORDER_ADD_FRAME, KeyCombination.parse("NumPlus")) is ( + ShortcutId.ORDER_INSERT_FRAME + ) + class TestAssign(BaseTestSuite): - """An assignment takes the combination from whichever action of the category holds it.""" + """An assignment takes the combination from whichever action of the category holds it, and the holder + keeps every other key it had. + """ @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): @@ -156,14 +233,18 @@ class TestCase(BaseRegularTestCase): test_cases, ids=lambda test_case: test_case.label, ) - def test_the_action_that_held_the_combination_is_left_unbound( + def test_the_action_that_held_the_combination_keeps_its_other_keys( self, test_case: TestCase, draft: ShortcutDraft, + shipped: ShortcutScheme, ) -> None: - edited = draft.assign(test_case.shortcut_id, KeyCombination.parse(test_case.written)) + taken = KeyCombination.parse(test_case.written) + edited = draft.assign(test_case.shortcut_id, (taken,)) - assert edited.combination(test_case.holder) is None + assert edited.keys(test_case.holder) == tuple( + key for key in shipped.shortcut(test_case.holder).combinations() if key != taken + ) @pytest.mark.parametrize( "test_case", @@ -176,24 +257,47 @@ def test_the_scheme_the_assignment_produces_reaches_the_action_it_named( draft: ShortcutDraft, ) -> None: combination = KeyCombination.parse(test_case.written) - scheme = draft.assign(test_case.shortcut_id, combination).scheme() + scheme = draft.assign(test_case.shortcut_id, (combination,)).scheme() assert scheme.claimant(test_case.shortcut_id.category, combination) is test_case.shortcut_id def test_an_assignment_leaves_the_draft_holding_keys_to_store(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)) + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)) assert edited.is_dirty is True - def test_the_actions_an_assignment_leaves_alone_keep_their_keys(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)) + def test_the_actions_an_assignment_leaves_alone_keep_their_keys( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)) - assert edited.combination(ShortcutId.REDO) == KeyCombination.parse("Ctrl+Y") + assert edited.keys(ShortcutId.REDO) == shipped.shortcut(ShortcutId.REDO).combinations() def test_an_action_given_the_keys_it_already_answers_keeps_them(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse("Ctrl+Z")) + edited = draft.assign(ShortcutId.UNDO, keys("Ctrl+Z")) + + assert edited.keys(ShortcutId.UNDO) == keys("Ctrl+Z") + + def test_a_list_taking_keys_from_two_actions_takes_each_from_its_holder( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + taken = ( + shipped.shortcut(ShortcutId.SAVE_PROJECT).combinations()[0], + shipped.shortcut(ShortcutId.REDO).aliases[0], + ) + + edited = draft.assign(ShortcutId.ABOUT_DIALOG, taken) - assert edited.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Z") + assert edited.holders(ShortcutId.ABOUT_DIALOG, taken) == {} + assert draft.holders(ShortcutId.ABOUT_DIALOG, taken) == { + taken[0]: ShortcutId.SAVE_PROJECT, + taken[1]: ShortcutId.REDO, + } + assert edited.keys(ShortcutId.REDO) == (shipped.shortcut(ShortcutId.REDO).combinations()[0],) class TestUnwritableCombination(BaseTestSuite): @@ -234,7 +338,7 @@ class TestCase(BaseRegularTestCase): ) def test_assigning_it_is_refused(self, test_case: TestCase, draft: ShortcutDraft) -> None: with pytest.raises(KeyError): - draft.assign(ShortcutId.UNDO, test_case.combination) + draft.assign(ShortcutId.UNDO, (test_case.combination,)) @pytest.mark.parametrize( "test_case", @@ -243,9 +347,9 @@ def test_assigning_it_is_refused(self, test_case: TestCase, draft: ShortcutDraft ) def test_the_action_keeps_the_keys_it_had(self, test_case: TestCase, draft: ShortcutDraft) -> None: with pytest.raises(KeyError): - draft.assign(ShortcutId.UNDO, test_case.combination) + draft.assign(ShortcutId.UNDO, (test_case.combination,)) - assert draft.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Z") + assert draft.keys(ShortcutId.UNDO) == keys("Ctrl+Z") assert draft.is_dirty is False @pytest.mark.parametrize( @@ -264,6 +368,15 @@ def test_a_cleared_action_stores_as_unbound(self, draft: ShortcutDraft) -> None: assert edited.overrides() == {"Undo": None} + def test_every_key_a_cleared_action_held_is_free_for_another( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + edited = draft.clear(ShortcutId.REDO) + + assert edited.holders(ShortcutId.ABOUT_DIALOG, shipped.shortcut(ShortcutId.REDO).combinations()) == {} + def test_the_keys_a_cleared_action_held_are_free_for_another(self, draft: ShortcutDraft) -> None: edited = draft.clear(ShortcutId.UNDO) @@ -279,7 +392,7 @@ class TestReset: def test_a_reset_draft_reads_the_keys_the_scheme_ships(self, shipped: ShortcutScheme) -> None: draft = ShortcutDraft.open(shipped, {"Undo": "Ctrl+Alt+U"}).reset() - assert draft.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Z") + assert draft.keys(ShortcutId.UNDO) == keys("Ctrl+Z") def test_a_reset_draft_stores_no_override(self, shipped: ShortcutScheme) -> None: draft = ShortcutDraft.open(shipped, {"Undo": "Ctrl+Alt+U"}).reset() @@ -297,25 +410,38 @@ def test_a_reset_of_a_draft_on_the_shipped_keys_leaves_it_as_it_was(self, draft: class TestScheme: def test_the_scheme_answers_the_keys_the_reader_gave(self, draft: ShortcutDraft) -> None: - scheme = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)).scheme() + scheme = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION, SECOND_FREE_COMBINATION)).scheme() - assert scheme.shortcut(ShortcutId.UNDO).display() == FREE_COMBINATION + assert scheme.shortcut(ShortcutId.UNDO).combinations() == keys(FREE_COMBINATION, SECOND_FREE_COMBINATION) + + def test_an_untouched_action_keeps_the_aliases_the_scheme_ships( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + scheme = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)).scheme() - def test_an_untouched_action_keeps_the_aliases_the_scheme_ships(self, draft: ShortcutDraft) -> None: - scheme = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)).scheme() + assert scheme.shortcut(ShortcutId.REDO).aliases == shipped.shortcut(ShortcutId.REDO).aliases - assert scheme.shortcut(ShortcutId.REDO).aliases == (KeyCombination.parse("Ctrl+Shift+Z"),) + def test_a_pressed_key_leaves_the_action_answering_its_aliases_too( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + """A pressed key becomes the main key the menus print, and every key the action had still answers.""" + pressed = KeyCombination.parse("Ctrl+Alt+I") + led = draft.keys_led_by(ShortcutId.ORDER_INSERT_FRAME, pressed) - def test_a_touched_action_answers_the_combination_it_was_given_alone(self, draft: ShortcutDraft) -> None: - scheme = draft.assign(ShortcutId.ORDER_INSERT_FRAME, KeyCombination.parse("Ctrl+Alt+I")).scheme() + shortcut = draft.assign(ShortcutId.ORDER_INSERT_FRAME, led).scheme().shortcut(ShortcutId.ORDER_INSERT_FRAME) - assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).aliases == () + assert shortcut.combination == pressed + assert shortcut.aliases == shipped.shortcut(ShortcutId.ORDER_INSERT_FRAME).combinations() def test_two_actions_trade_the_combinations_they_held(self, draft: ShortcutDraft) -> None: """Every edit is read at once, so a swap arrives without either action holding both keys.""" - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse("Ctrl+Y")).assign( + edited = draft.assign(ShortcutId.UNDO, keys("Ctrl+Y")).assign( ShortcutId.REDO, - KeyCombination.parse("Ctrl+Z"), + keys("Ctrl+Z"), ) scheme = edited.scheme() @@ -327,7 +453,9 @@ def test_a_draft_on_the_shipped_keys_produces_the_scheme_it_opened_on(self, draf def test_every_key_the_table_names_produces_a_scheme_that_resolves(self, draft: ShortcutDraft) -> None: """What a reader may assign is what the application then runs on, key for key.""" - assigned = {key: draft.assign(ShortcutId.UNDO, KeyCombination(key, CTRL)).scheme() for key in KEY_DISPLAY_NAMES} + assigned = { + key: draft.assign(ShortcutId.UNDO, (KeyCombination(key, CTRL),)).scheme() for key in KEY_DISPLAY_NAMES + } assert all( scheme.shortcut(ShortcutId.UNDO).combination == KeyCombination(key, CTRL) @@ -337,31 +465,60 @@ def test_every_key_the_table_names_produces_a_scheme_that_resolves(self, draft: class TestOverrides: def test_an_edit_stores_under_the_name_a_keybinding_file_writes(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)) + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)) assert edited.overrides() == {"Undo": FREE_COMBINATION} def test_an_edit_stores_the_combination_as_it_reads(self, draft: ShortcutDraft) -> None: """A stored preference is written the way the dialog shows it, whatever the reader typed.""" - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse("shift+ctrl+alt+u")) + edited = draft.assign(ShortcutId.UNDO, keys("shift+ctrl+alt+u")) assert edited.overrides() == {"Undo": "Ctrl+Alt+Shift+U"} def test_a_displaced_action_stores_as_unbound(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+S")) + edited = draft.assign(ShortcutId.ABOUT_DIALOG, keys("Ctrl+S")) assert edited.overrides() == {"AboutDialog": "Ctrl+S", "SaveProject": None} + def test_an_edit_stores_every_key_joined_by_commas(self, draft: ShortcutDraft) -> None: + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION, SECOND_FREE_COMBINATION)) + + assert edited.overrides() == {"Undo": f"{FREE_COMBINATION}, {SECOND_FREE_COMBINATION}"} + + def test_a_list_reopens_on_every_key_it_stored(self, draft: ShortcutDraft) -> None: + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION, "Comma", SECOND_FREE_COMBINATION)) + + reopened = ShortcutDraft.open(draft.base, edited.overrides()) + + assert reopened.keys(ShortcutId.UNDO) == keys(FREE_COMBINATION, "Comma", SECOND_FREE_COMBINATION) + def test_the_actions_the_reader_left_alone_store_nothing(self, draft: ShortcutDraft) -> None: """A preference states the actions the reader touched, so the rest follow the scheme.""" - edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)) + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)) assert set(edited.overrides()) == {"Undo"} def test_a_stored_draft_reopens_on_the_keys_it_stored(self, draft: ShortcutDraft) -> None: - edited = draft.assign(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+S")) + edited = draft.assign(ShortcutId.ABOUT_DIALOG, keys("Ctrl+S")) reopened = ShortcutDraft.open(draft.base, edited.overrides()) assert reopened.edits == edited.edits assert reopened.is_dirty is False + + +class TestEveryKey: + def test_taking_an_alias_leaves_its_holder_answering_its_main_key( + self, + draft: ShortcutDraft, + shipped: ShortcutScheme, + ) -> None: + redo = shipped.shortcut(ShortcutId.REDO) + edited = draft.assign(ShortcutId.ABOUT_DIALOG, (redo.aliases[0],)) + + assert edited.keys(ShortcutId.REDO) == (redo.combinations()[0],) + + def test_a_stored_list_reads_back_as_the_preference_it_came_from(self, shipped: ShortcutScheme) -> None: + overrides: Dict[str, Optional[str]] = {"Redo": "Ctrl+Alt+R, Ctrl+Alt+Shift+R"} + + assert ShortcutDraft.open(shipped, overrides).overrides() == overrides diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py index 5efd6a592..084fe4980 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -171,34 +171,42 @@ def test_each_category_answers_a_shared_combination_with_its_own_action(self, sh class TestWithBinding: def test_an_action_answers_the_combination_it_is_given(self, shipped: ShortcutScheme) -> None: - scheme = shipped.with_binding(ShortcutId.UNDO, KeyCombination.parse("Ctrl+Alt+U")) + scheme = shipped.with_binding(ShortcutId.UNDO, (KeyCombination.parse("Ctrl+Alt+U"),)) assert scheme.shortcut(ShortcutId.UNDO).display() == "Ctrl+Alt+U" - def test_an_action_answers_that_combination_alone(self, shipped: ShortcutScheme) -> None: - scheme = shipped.with_binding(ShortcutId.ORDER_INSERT_FRAME, KeyCombination.parse("Ctrl+Alt+I")) + def test_an_action_given_one_key_answers_that_key_alone(self, shipped: ShortcutScheme) -> None: + scheme = shipped.with_binding(ShortcutId.ORDER_INSERT_FRAME, (KeyCombination.parse("Ctrl+Alt+I"),)) assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).aliases == () + def test_an_action_given_several_keys_answers_each_and_displays_the_first(self, shipped: ShortcutScheme) -> None: + given = (KeyCombination.parse("Ctrl+Alt+I"), KeyCombination.parse("Ctrl+Alt+Shift+I")) + + scheme = shipped.with_binding(ShortcutId.ORDER_INSERT_FRAME, given) + + assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).combinations() == given + assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).display() == given[0].display() + def test_an_action_given_no_combination_is_left_unbound(self, shipped: ShortcutScheme) -> None: - scheme = shipped.with_binding(ShortcutId.UNDO, None) + scheme = shipped.with_binding(ShortcutId.UNDO, ()) assert scheme.shortcut(ShortcutId.UNDO).combinations() == () def test_a_combination_the_category_already_answers_raises(self, shipped: ShortcutScheme) -> None: """An editor is told which action holds the keys, so the reader decides who keeps them.""" with pytest.raises(SystemError): - shipped.with_binding(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+S")) + shipped.with_binding(ShortcutId.ABOUT_DIALOG, (KeyCombination.parse("Ctrl+S"),)) def test_a_combination_another_category_holds_stands(self, shipped: ShortcutScheme) -> None: - scheme = shipped.with_binding(ShortcutId.ABOUT_DIALOG, KeyCombination.parse(TABLE_COMBINATION)) + scheme = shipped.with_binding(ShortcutId.ABOUT_DIALOG, (KeyCombination.parse(TABLE_COMBINATION),)) combination = KeyCombination.parse(TABLE_COMBINATION) assert scheme.claimant(ShortcutCategory.APPLICATION, combination) is ShortcutId.ABOUT_DIALOG def test_a_combination_naming_no_key_raises(self, shipped: ShortcutScheme) -> None: with pytest.raises(KeyError): - shipped.with_binding(ShortcutId.UNDO, KeyCombination(UNNAMED_KEY)) + shipped.with_binding(ShortcutId.UNDO, (KeyCombination(UNNAMED_KEY),)) class TestWithBindings: @@ -206,8 +214,8 @@ def test_two_actions_trade_the_combinations_they_held(self, shipped: ShortcutSch """A whole set is read at once, so a swap arrives without either action holding both keys.""" scheme = shipped.with_bindings( { - ShortcutId.UNDO: KeyCombination.parse("Ctrl+Y"), - ShortcutId.REDO: KeyCombination.parse("Ctrl+Z"), + ShortcutId.UNDO: (KeyCombination.parse("Ctrl+Y"),), + ShortcutId.REDO: (KeyCombination.parse("Ctrl+Z"),), }, ) @@ -215,7 +223,7 @@ def test_two_actions_trade_the_combinations_they_held(self, shipped: ShortcutSch assert scheme.claimant(ShortcutCategory.APPLICATION, KeyCombination.parse("Ctrl+Z")) is ShortcutId.REDO def test_the_actions_no_binding_names_keep_the_scheme_s_keys(self, shipped: ShortcutScheme) -> None: - scheme = shipped.with_bindings({ShortcutId.UNDO: KeyCombination.parse("Ctrl+Alt+U")}) + scheme = shipped.with_bindings({ShortcutId.UNDO: (KeyCombination.parse("Ctrl+Alt+U"),)}) assert scheme.shortcut(ShortcutId.REDO) == shipped.shortcut(ShortcutId.REDO) @@ -233,12 +241,28 @@ def test_the_keys_the_override_replaces_stop_reaching_the_action(self, shipped: assert scheme.action(ShortcutCategory.APPLICATION, _press("Ctrl+Z")) is None def test_an_override_states_the_whole_of_what_reaches_the_action(self, shipped: ShortcutScheme) -> None: - """A reader names one combination, so the keypad alias the scheme shipped goes with it.""" + """An override lists every key of its action, so one naming a single key leaves the shipped keypad + alias out. + """ scheme = shipped.with_overrides({"OrderInsertFrame": "Ctrl+Alt+I"}) assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).aliases == () assert scheme.action(ShortcutCategory.ORDER, _press("Num+")) is None + def test_an_override_listing_several_keys_reaches_the_action_from_each(self, shipped: ShortcutScheme) -> None: + scheme = shipped.with_overrides({"OrderInsertFrame": "Ctrl+Alt+I, Num+"}) + + assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).display() == "Ctrl+Alt+I" + assert scheme.action(ShortcutCategory.ORDER, _press("Ctrl+Alt+I")) is ShortcutId.ORDER_INSERT_FRAME + assert scheme.action(ShortcutCategory.ORDER, _press("Num+")) is ShortcutId.ORDER_INSERT_FRAME + + def test_an_override_naming_no_key_in_one_place_is_left_out_whole(self, shipped: ShortcutScheme) -> None: + """An override that is unreadable in part costs only itself, and its action keeps the shipped keys.""" + scheme = shipped.with_overrides({"Redo": "Ctrl+Alt+R, Ctrl+Gibberish", "Undo": "Ctrl+Alt+U"}) + + assert scheme.shortcut(ShortcutId.REDO) == shipped.shortcut(ShortcutId.REDO) + assert scheme.shortcut(ShortcutId.UNDO).display() == "Ctrl+Alt+U" + def test_a_rebound_action_keeps_the_transparency_its_role_carries(self, shipped: ShortcutScheme) -> None: """Switching tabs outranks text entry whichever keys it answers to.""" scheme = shipped.with_overrides({"NextTab": "Ctrl+Alt+N"}) diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py index 96e715835..f8406cdf5 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py @@ -38,22 +38,30 @@ def test_an_entry_naming_no_key_raises(self) -> None: class TestRebound: def test_a_rebound_entry_answers_the_combination_the_reader_named(self) -> None: - entry = WrittenShortcut(combination="Ctrl+Y").rebound("Ctrl+Alt+R") + entry = WrittenShortcut(combination="Ctrl+Y").rebound(("Ctrl+Alt+R",)) assert entry.combination == "Ctrl+Alt+R" - def test_a_rebound_entry_answers_that_combination_alone(self) -> None: - entry = WrittenShortcut(combination="Ctrl+Y", aliases=("Ctrl+Shift+Z",)).rebound("Ctrl+Alt+R") + def test_a_rebound_entry_answers_the_keys_named_and_those_alone(self) -> None: + """A reader states every key of the action, so one key leaves the earlier aliases out.""" + entry = WrittenShortcut(combination="Ctrl+Y", aliases=("Ctrl+Shift+Z",)).rebound(("Ctrl+Alt+R",)) assert entry.aliases == () + def test_a_rebound_entry_displays_the_first_key_and_answers_the_rest(self) -> None: + entry = WrittenShortcut(combination="Ctrl+Y").rebound(("Ctrl+Alt+R", "Ctrl+Shift+Z")) + + assert entry.combination == "Ctrl+Alt+R" + assert entry.aliases == ("Ctrl+Shift+Z",) + def test_an_entry_rebound_to_no_combination_is_left_unbound(self) -> None: """A reader takes an action's keys away by giving it none.""" - entry = WrittenShortcut(combination="Ctrl+Y").rebound(None) + entry = WrittenShortcut(combination="Ctrl+Y", aliases=("Ctrl+Shift+Z",)).rebound(()) assert entry.combination is None + assert entry.aliases == () def test_a_rebound_entry_keeps_the_transparency_the_action_carries(self) -> None: - entry = WrittenShortcut(combination="Ctrl+PgDn", field_transparent=True).rebound("Ctrl+Alt+N") + entry = WrittenShortcut(combination="Ctrl+PgDn", field_transparent=True).rebound(("Ctrl+Alt+N",)) assert entry.field_transparent is True From 2117219bb76c35fdb954e9c10257a4f4227e05dc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 16:15:07 +0200 Subject: [PATCH 58/84] Fixed: a stepper hold outliving a release made outside the window, sharing the bar graph's rule that a press ends once the button reads up --- .../ui/elements/graphs/bar.py | 58 +++++++++++------- .../ui/elements/plus_minus_buttons.py | 57 +++++++++++++----- .../utils/gui/press.py | 36 +++++++++++ .../ui/elements/graphs/test_bar.py | 4 +- .../ui/elements/test_plus_minus_buttons.py | 40 +++++++++++-- .../utils/gui/test_press.py | 59 +++++++++++++++++++ 6 files changed, 209 insertions(+), 45 deletions(-) create mode 100644 src/sampletones_application/utils/gui/press.py create mode 100644 tests/unit/sampletones_application/utils/gui/test_press.py diff --git a/src/sampletones_application/ui/elements/graphs/bar.py b/src/sampletones_application/ui/elements/graphs/bar.py index d60a13b2a..a0a327084 100644 --- a/src/sampletones_application/ui/elements/graphs/bar.py +++ b/src/sampletones_application/ui/elements/graphs/bar.py @@ -26,6 +26,7 @@ ) from sampletones_application.utils.gui.keyboard.modifiers import CTRL_ALT_SHIFT, capture_modifiers from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color +from sampletones_application.utils.gui.press import LeftPress from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.utils.palette.colors.faded import FadedColor from sampletones_shared.types.application import Sender @@ -42,6 +43,19 @@ class DrawStroke: value: float +@dataclass +class HeldStroke: + """The stroke a held press draws: the bar it drew on the frame before, which the next bar joins.""" + + last: Optional[DrawStroke] = None + + def take_last(self) -> Optional[DrawStroke]: + """The bar drawn on the frame before, leaving the stroke to record the bar this frame draws.""" + last = self.last + self.last = None + return last + + class GUIBarGraph(GUIGraph[BarLayer]): """A plot of bars the reader draws by pressing and dragging over them. @@ -93,8 +107,7 @@ def __init__( self.on_bar_point_clicked: Optional[OnBarPointClickedCallback] = None self.on_bar_point_hovered: Optional[OnBarPointHoveredCallback] = None - self._draw_stroke: Optional[DrawStroke] = None - self._pressed = False + self._press: LeftPress[HeldStroke] = LeftPress() _min_x = layout.graph.min_x _max_x = layout.graph.max_x @@ -162,13 +175,12 @@ def _setup_handlers(self) -> None: def _on_press(self, sender: Sender) -> None: """Starts a press on the plot, which draws the bar it lands on.""" - self._pressed = True + self._press.begin(HeldStroke()) self._on_mouse_action(sender) def _on_release(self, _sender: Sender) -> None: """Ends the press, so the next stroke starts afresh.""" - self._pressed = False - self._draw_stroke = None + self._press.end() def _on_hover(self, sender: Sender, app_data: int, user_data: Any) -> None: super()._on_hover(sender, app_data, user_data) @@ -354,8 +366,15 @@ def _update_ticks(self) -> None: dpg.set_axis_ticks(self.y_axis_tag, tuple(zip(tick_labels, self.y_ticks))) def _on_mouse_action(self, _sender: Sender) -> None: - previous_stroke = self._draw_stroke - self._draw_stroke = None + """Answers one frame of the pointer over the plot, drawing the bar under it while a press is held. + + The mouse move runs whether or not the button is down, so a release the plot never heard, + such as one outside the window, ends the press here once the button reads up, and a later + press begun elsewhere draws nothing. A stroke joins the bar it reaches to the one it drew on + the frame before. + """ + stroke = self._press.held() + previous_stroke = None if stroke is None else stroke.take_last() if capture_modifiers() & CTRL_ALT_SHIFT: return @@ -385,20 +404,14 @@ def _on_mouse_action(self, _sender: Sender) -> None: self._set_hover_bar_position(bar_index, clamped_y) self.call(self.on_bar_point_hovered, name, bar_index) - if self._holds_press() and self._presses_a_bar(mouse_y): - self._draw_bar(layer, bar_index, clamped_y, previous_stroke) - - def _holds_press(self) -> bool: - """Whether a press that went down on the plot is still held. - - A release the plot never heard, such as one outside the window, ends the press here, once the - button reads up, so a later press begun elsewhere draws nothing. - """ - if self._pressed and not dpg.is_mouse_button_down(dpg.mvMouseButton_Left): - self._pressed = False - self._draw_stroke = None - - return self._pressed + if stroke is not None and self._presses_a_bar(mouse_y): + self._draw_bar( + layer, + bar_index, + clamped_y, + stroke, + previous_stroke, + ) def _presses_a_bar(self, mouse_y: float) -> bool: """Whether a press at ``mouse_y`` stands on the grid the bars are drawn across. @@ -413,6 +426,7 @@ def _draw_bar( layer: BarLayer, bar_index: int, value: float, + stroke: HeldStroke, previous_stroke: Optional[DrawStroke], ) -> None: if previous_stroke is not None and previous_stroke.index != bar_index: @@ -426,6 +440,6 @@ def _draw_bar( else: layer.y_data[bar_index] = value - self._draw_stroke = DrawStroke(index=bar_index, value=value) + stroke.last = DrawStroke(index=bar_index, value=value) self._update_display() self.call(self.on_bar_point_clicked, layer.y_data) diff --git a/src/sampletones_application/ui/elements/plus_minus_buttons.py b/src/sampletones_application/ui/elements/plus_minus_buttons.py index edf5c2758..5a7f346aa 100644 --- a/src/sampletones_application/ui/elements/plus_minus_buttons.py +++ b/src/sampletones_application/ui/elements/plus_minus_buttons.py @@ -19,6 +19,7 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_delete_item +from sampletones_application.utils.gui.press import LeftPress from sampletones_shared.constants.symbols import MINUS, PLUS from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback @@ -50,9 +51,10 @@ class GUIPlusMinusButtons(CallbackMixin): sign occupies. With ``hold_repeat`` a held button repeats its press after an initial delay, matching the stepping feel of a numeric field; otherwise each button fires once per click. A hold belongs to the button the press went down on and repeats while the - pointer stays on it, so a press carried in from elsewhere steps nothing. Either button - can be enabled or disabled independently, so a control can gray out a step that would - have no effect. + pointer stays on it, so a press carried in from elsewhere steps nothing. A hold ends on + the release, and on the first mouse move with the button up, which ends one whose release + landed outside the window. Either button can be enabled or disabled independently, so a + control can gray out a step that would have no effect. """ def __init__( @@ -77,7 +79,7 @@ def __init__( self._hold_repeat = hold_repeat self._font = font - self._held: Optional[HeldPress] = None + self._press: LeftPress[HeldPress] = LeftPress() self._table_tag = compose_tag(tag, SUF_TABLE) self._decrement_button_tag = compose_tag(tag, SUF_BUTTON_DECREMENT) @@ -209,6 +211,7 @@ def _setup_button_hold_handlers(self) -> None: button=dpg.mvMouseButton_Left, callback=self._on_mouse_release, ) + dpg.add_mouse_move_handler(callback=self._on_mouse_move) @staticmethod def _bind_press_handler( @@ -248,9 +251,16 @@ def _on_increment_pressed(self) -> None: def _arm_hold(self, direction: int) -> None: """Starts a hold on the button a press went down on, with a longer delay before the first repeat.""" - self._held = HeldPress( - direction=direction, - remaining=HOLD_INITIAL_DELAY_FACTOR * self._layout.hold_delay, + self._press.begin( + HeldPress( + direction=direction, + remaining=HOLD_INITIAL_DELAY_FACTOR * self._layout.hold_delay, + ) + ) + + def _buttons_exist(self) -> bool: + return bool(dpg.does_item_exist(self._decrement_button_tag)) and bool( + dpg.does_item_exist(self._increment_button_tag) ) def _on_mouse_down( @@ -259,7 +269,7 @@ def _on_mouse_down( _app_data: Any, _user_data: Any, ) -> None: - if not dpg.does_item_exist(self._decrement_button_tag) or not dpg.does_item_exist(self._increment_button_tag): + if not self._buttons_exist(): dpg_delete_item(sender) return @@ -267,12 +277,26 @@ def _on_mouse_down( if direction is not None: self._step(direction) + def _on_mouse_move( + self, + sender: Sender, + _app_data: Any, + _user_data: Any, + ) -> None: + """Ends a hold once the button reads up, from a handler that runs whether or not it is down.""" + if not self._buttons_exist(): + dpg_delete_item(sender) + return + + self._press.settle() + def _held_button_hovered(self) -> bool: """Whether the pointer stands on the button the hold began on.""" - if self._held is None: + held = self._press.held() + if held is None: return False - button = self._increment_button if self._held.direction > 0 else self._decrement_button + button = self._increment_button if held.direction > 0 else self._decrement_button return button is not None and bool(button.is_item_hovered()) def _on_mouse_release( @@ -281,7 +305,7 @@ def _on_mouse_release( _app_data: Any, _user_data: Any, ) -> None: - self._held = None + self._press.end() def _advance_hold( self, @@ -300,12 +324,13 @@ def _advance_hold( Returns: Optional[int]: The step direction on a frame that repeats the press, otherwise ``None``. """ - if self._held is None or not hovered: + held = self._press.held() + if held is None or not hovered: return None - self._held.remaining -= delta_time - if self._held.remaining > 0: + held.remaining -= delta_time + if held.remaining > 0: return None - self._held.remaining = self._layout.hold_delay - return self._held.direction + held.remaining = self._layout.hold_delay + return held.direction diff --git a/src/sampletones_application/utils/gui/press.py b/src/sampletones_application/utils/gui/press.py new file mode 100644 index 000000000..dbd790f30 --- /dev/null +++ b/src/sampletones_application/utils/gui/press.py @@ -0,0 +1,36 @@ +from typing import Generic, Optional, TypeVar + +import dearpygui.dearpygui as dpg + +PressT = TypeVar("PressT") + + +class LeftPress(Generic[PressT]): + """A left-button press that went down on a widget, with what the widget keeps while it is held. + + A widget begins the press it hears go down on it and ends it on the release DearPyGui reports. + DearPyGui reports no release made outside the window, so a press also ends once it is read with + the left button up. A widget reads it from a handler that runs with the button up as well, such + as a mouse move, so a press begun elsewhere later finds nothing held. + """ + + def __init__(self) -> None: + self._held: Optional[PressT] = None + + def begin(self, held: PressT) -> None: + """Keeps ``held`` for the press that went down, in place of any press before it.""" + self._held = held + + def end(self) -> None: + """Ends the press, on the release DearPyGui reports.""" + self._held = None + + def settle(self) -> None: + """Ends the press once the left button reads up, which is a release DearPyGui never reported.""" + if self._held is not None and not dpg.is_mouse_button_down(dpg.mvMouseButton_Left): + self._held = None + + def held(self) -> Optional[PressT]: + """What the press keeps while the left button stays down, and ``None`` once it has ended.""" + self.settle() + return self._held diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py b/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py index af93e32c3..54b2dfa7a 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_bar.py @@ -7,6 +7,7 @@ from sampletones_application.ui.elements.graphs import bar as bar_module from sampletones_application.ui.elements.graphs.bar import GUIBarGraph from sampletones_application.ui.elements.graphs.layers.bar import BarLayer +from sampletones_application.utils.gui.press import LeftPress from sampletones_application.utils.palette.colors.literal import LiteralColor BAND_SHARE: Final[float] = 0.2 @@ -41,8 +42,7 @@ def _graph() -> GUIBarGraph: bar_weight=BAR_WEIGHT, ) } - graph._draw_stroke = None - graph._pressed = False + graph._press = LeftPress() graph.on_bar_point_clicked = None graph.on_bar_point_hovered = None return graph diff --git a/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py b/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py index a7376ed64..967d39e26 100644 --- a/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py +++ b/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py @@ -2,6 +2,7 @@ from typing import Callable, List, Optional from unittest.mock import MagicMock +import dearpygui.dearpygui as dpg import pytest from sampletones_application.layout.general.plus_minus_buttons import ( @@ -12,6 +13,7 @@ HOLD_INITIAL_DELAY_FACTOR, GUIPlusMinusButtons, ) +from sampletones_application.utils.gui.press import LeftPress LAYOUT = PlusMinusButtonsLayout( button_width=30, @@ -21,14 +23,19 @@ FIRST_REPEAT = HOLD_INITIAL_DELAY_FACTOR * LAYOUT.hold_delay FRAME = LAYOUT.hold_delay / 10 +DECREMENT_TAG = "decrement" +INCREMENT_TAG = "increment" @dataclass class Pointer: - """Which of the pair's buttons the pointer stands on, as their hover reads it.""" + """Which of the pair's buttons the pointer stands on, as their hover reads it, and whether the left + button is down. + """ on_increment: bool = False on_decrement: bool = False + down: bool = True def _button(hovered: Callable[[], bool]) -> GUIButton: @@ -43,14 +50,18 @@ def pointer() -> Pointer: @pytest.fixture -def buttons(pointer: Pointer) -> GUIPlusMinusButtons: +def buttons(pointer: Pointer, monkeypatch: pytest.MonkeyPatch) -> GUIPlusMinusButtons: """A pair carrying only the state the tested methods touch, bypassing the DearPyGui-dependent - constructor.""" + constructor, with the left button read from ``pointer``.""" + monkeypatch.setattr(dpg, "is_mouse_button_down", lambda button: pointer.down) + monkeypatch.setattr(dpg, "does_item_exist", lambda tag: True) pair = GUIPlusMinusButtons.__new__(GUIPlusMinusButtons) pair.on_increment = None pair.on_decrement = None pair._layout = LAYOUT - pair._held = None + pair._decrement_button_tag = DECREMENT_TAG + pair._increment_button_tag = INCREMENT_TAG + pair._press = LeftPress() pair._increment_button = _button(lambda: pointer.on_increment) pair._decrement_button = _button(lambda: pointer.on_decrement) return pair @@ -139,5 +150,24 @@ def test_a_release_ends_the_hold( buttons._on_mouse_release(0, None, None) - assert buttons._held is None + assert buttons._press.held() is None assert _frame(buttons, FIRST_REPEAT) is None + + def test_a_release_the_buttons_missed_ends_the_hold( + self, + buttons: GUIPlusMinusButtons, + pointer: Pointer, + ) -> None: + """A mouse move with the button up ends the hold, so a press later carried onto the button steps + nothing. + """ + pointer.on_increment = True + buttons._on_increment_pressed() + + pointer.on_increment = False + pointer.down = False + buttons._on_mouse_move(0, None, None) + pointer.down = True + pointer.on_increment = True + + assert [_frame(buttons, FIRST_REPEAT) for _ in range(3)] == [None, None, None] diff --git a/tests/unit/sampletones_application/utils/gui/test_press.py b/tests/unit/sampletones_application/utils/gui/test_press.py new file mode 100644 index 000000000..808fad782 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_press.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass +from typing import Final + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.utils.gui.press import LeftPress + +HELD: Final[str] = "held" + + +@dataclass +class Button: + """Whether the left mouse button reads down.""" + + down: bool = True + + +@pytest.fixture +def button(monkeypatch: pytest.MonkeyPatch) -> Button: + button = Button() + monkeypatch.setattr(dpg, "is_mouse_button_down", lambda _button: button.down) + return button + + +class TestALeftPress: + """A press stands from its beginning until its release, or until it is read with the button up.""" + + def test_a_press_keeps_what_it_began_with_while_the_button_is_down(self, button: Button) -> None: + press: LeftPress[str] = LeftPress() + press.begin(HELD) + + assert press.held() == HELD + + def test_a_reported_release_ends_the_press(self, button: Button) -> None: + press: LeftPress[str] = LeftPress() + press.begin(HELD) + + press.end() + + assert press.held() is None + + def test_a_release_never_reported_ends_the_press_once_the_button_reads_up(self, button: Button) -> None: + """A later press keeps the button down again, and finds nothing held.""" + press: LeftPress[str] = LeftPress() + press.begin(HELD) + + button.down = False + press.settle() + button.down = True + + assert press.held() is None + + def test_reading_with_the_button_up_ends_the_press(self, button: Button) -> None: + press: LeftPress[str] = LeftPress() + press.begin(HELD) + button.down = False + + assert press.held() is None From 95785ba3d5af29d571b85e79bbf34bd3128e80e5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 16:17:45 +0200 Subject: [PATCH 59/84] Changed: the no-project notice naming voices for every gesture that adds one, with a scenario pressing New instrument's key with no project open --- src/sampletones_config/lang/en.yaml | 2 +- .../sequencer/voices/test_no_project_key.py | 83 +++++++++++++++++++ tests/suite/screens/screen.py | 2 + 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/screens/sequencer/voices/test_no_project_key.py diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index eb5b85d40..34b72fdda 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -105,7 +105,7 @@ global.dialog.message.configuration_recovery_earlier_version: "an earlier versio global.dialog.message.configuration_recovery_list_header: "The following settings could not be carried over and were discarded:" global.dialog.message.configuration_recovery_path_prefix: "You can edit the configuration file directly:" global.dialog.message.audio_playback_error: "Audio playback error" -global.dialog.message.no_project_open: "No project is open. Create or open a project before adding a reconstruction." +global.dialog.message.no_project_open: "No project is open. Open or create a project to add voices." global.dialog.message.no_audio_output: "Can't play sound: no audio output is available." global.dialog.message.remove_voice: "The voice \"{name}\" is used by one or more patterns. Removing it will clear every row that references it. Remove it anyway?" global.dialog.message.change_nes_frequency: "Song playback already follows the new rate. Editing a reconstruction in the Reconstructions tab still uses its own rate until you retune it. Retune all samples to match?" diff --git a/tests/screens/sequencer/voices/test_no_project_key.py b/tests/screens/sequencer/voices/test_no_project_key.py new file mode 100644 index 000000000..bdc261faa --- /dev/null +++ b/tests/screens/sequencer/voices/test_no_project_key.py @@ -0,0 +1,83 @@ +import operator +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.shortcuts import ShortcutsConfig +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.sequencer import leave_letting_the_project_go +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.songs import sequencer_world + +NEW_INSTRUMENT_KEYS: Final[KeyCombination] = KeyCombination.parse("F12") +NO_PROJECT_TITLE: Final[str] = "global.dialog.title.no_project_open" +NO_PROJECT_MESSAGE: Final[str] = "global.dialog.message.no_project_open" +SETTLING_FRAMES: Final[int] = 20 + + +@pytest.fixture +def world() -> World: + """The Sequencer's home, its settings giving New instrument a key of its own.""" + sequencer = sequencer_world() + return World( + state=sequencer.state, + application_config=ApplicationConfig( + shortcuts=ShortcutsConfig( + overrides={ShortcutId.NEW_INSTRUMENT.value: NEW_INSTRUMENT_KEYS.display()}, + ), + ), + config=None, + files=sequencer.files, + ) + + +@pytest.fixture +def startup() -> Startup: + """The application opens with no project and no reconstruction.""" + return Startup(reconstruction=None, project=None) + + +class TestTheNewInstrumentKeyWithNoProjectOpen: + """With no project open, the key given to New instrument tells the reader to open a project and adds no voice. + + The key brings up the notice in the words the language file gives it, OK dismisses it, and the voice list + stays empty. A new project then takes the same key, which adds one voice. + """ + + def test_the_key_tells_the_reader_and_adds_nothing(self, screen: Screen) -> None: + voices = screen.sequencer.voices + notice = screen.no_project_notice + + def the_key_tells_the_reader(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + screen.frames(SETTLING_FRAMES) + assert voices.names() == [] + + screen.press_shortcut(ShortcutId.NEW_INSTRUMENT) + + screen.expect(notice.is_shown, bool, description="the notice that no project is open") + assert notice.prompt.title() == screen.words(NO_PROJECT_TITLE) + assert notice.words() == screen.words(NO_PROJECT_MESSAGE) + notice.dismiss() + screen.expect(notice.is_shown, operator.not_, description="the notice dismissed") + assert voices.names() == [] + + def a_new_project_takes_the_key(screen: Screen) -> None: + screen.project.create() + screen.expect(voices.new_instrument_answers, bool, description="New instrument answering") + + screen.press_shortcut(ShortcutId.NEW_INSTRUMENT) + + screen.expect(lambda: len(voices.names()), (1).__eq__, description="one voice") + assert not notice.is_shown() + + screen.scenario( + the_key_tells_the_reader, + a_new_project_takes_the_key, + leave_letting_the_project_go, + ).run() diff --git a/tests/suite/screens/screen.py b/tests/suite/screens/screen.py index 0517e9b7d..997c4cbfd 100644 --- a/tests/suite/screens/screen.py +++ b/tests/suite/screens/screen.py @@ -13,6 +13,7 @@ TAG_GLOBAL_DIALOG_ERROR, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND, TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT, + TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN, TAG_GLOBAL_STATUS_BAR, TAG_GLOBAL_WINDOW_MAIN, ) @@ -117,6 +118,7 @@ def __init__( self.error_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_ERROR) self.file_not_found_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_FILE_NOT_FOUND) self.no_output_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_AUDIO_OUTPUT) + self.no_project_notice = Notice(bridge, hand, TAG_GLOBAL_DIALOG_NO_PROJECT_OPEN) def expect( self, From 8e023b0e47f1794521270fe0a39a30298e86e6ae Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 16:25:17 +0200 Subject: [PATCH 60/84] Fixed: a question that waited for the screen asking about what the answer before it had settled --- docs/development/application/dialogs.md | 25 ++- .../coordinators/project.py | 96 ++++++---- .../coordinators/reconstruction.py | 58 +++++- .../coordinators/tabs/instructions.py | 10 +- .../coordinators/tabs/main.py | 10 +- .../utils/callbacks/gates.py | 34 ++++ .../utils/gui/modal_queue.py | 107 ++++++++--- .../prompts/closing/test_over_a_question.py | 42 ++++- tests/suite/questions.py | 97 ++++++++++ tests/suite/screens/vocabulary/dialogs.py | 1 + .../coordinators/tabs/test_instructions.py | 24 +++ .../coordinators/tabs/test_main.py | 24 +++ .../coordinators/test_project.py | 104 +++++++++++ .../coordinators/test_reconstruction.py | 98 +++++++++- .../services/song_player/test_song_player.py | 12 +- .../test_application_exit.py | 174 ++++++++++++++++++ .../test_application_gestures.py | 82 ++++++++- .../utils/callbacks/test_gates.py | 78 ++++++++ .../utils/gui/test_modal_queue.py | 97 ++++++++++ 19 files changed, 1086 insertions(+), 87 deletions(-) create mode 100644 tests/suite/questions.py diff --git a/docs/development/application/dialogs.md b/docs/development/application/dialogs.md index 698fea7de..f743227a4 100644 --- a/docs/development/application/dialogs.md +++ b/docs/development/application/dialogs.md @@ -51,13 +51,21 @@ another one left in meets the same fate, since that frame still draws the one th The screen therefore belongs to one conversation at a time. A conversation is a dialog, the modals it hands the screen to while it steps aside, and the ones its answers raise. A modal asked for from anywhere -else, such as the report of a job that finished or a prompt raised by a gesture that waited for edits, -waits in line. It opens once the conversation holding the screen has ended, a frame after its last window -left, and the line opens in the order it was asked. A dialog asked for again while it waits keeps its place -with the newer request, and one hidden while it waits leaves the line. - -`ModalQueue` (`utils/gui/modal_queue.py`) keeps the line, and `GUIWindow.show` is the only way into it, so -a caller raises a dialog whenever it has one to raise and never waits a frame of its own for the screen. A +else, such as the report of a job that finished, waits in line. It opens once the conversation holding the +screen has ended, a frame after its last window left, and the line opens in the order it was asked. A dialog +asked for again while it waits keeps its place with the newer request, and one hidden while it waits leaves +the line. + +**A question reads what it asks about once the screen is free for it.** A guard with something to ask +takes a turn in the line, and reads its state again when the line reaches that turn. The conversation that +held the screen has settled what it changes by then. An exit asked for while Close project asks therefore +finds the project Discard closed, and asks nothing about it. A guard with nothing to ask lets the request +through at once, whatever holds the screen, so closing the window over a dialog leaves at once when nothing +is unsaved. A turn that opens no question lets the line go on to the next window in the same frame. + +`ModalQueue` (`utils/gui/modal_queue.py`) keeps the line. `GUIWindow.show` is the only way a window enters +it, and `ModalQueue.when_free` is how a guard takes its turn, through `asking` (`utils/callbacks/gates.py`). +A caller raises a dialog whenever it has one to raise and never waits a frame of its own for the screen. A window that reports work under way and leaves the rest of the interface live beside it is no modal, so it opens at once. @@ -93,7 +101,8 @@ before a question or after its answer. A request therefore always ends in one of While a conversation stands, the same gesture asked for again asks nothing, so two closes before the first is answered ask once. Once the conversation has ended, the gesture asks again. The span covers the wait for the -edits of the open reconstruction too, so a gesture repeated while an edit is on its way asks once it lands. +edits of the open reconstruction and the turn in the line too. A gesture repeated while an edit is on its way +asks once the edit lands, and one repeated while another conversation holds the screen asks once it ends. `SingleFlight` (`utils/callbacks/gates.py`) holds the conversation, and the composition root wraps every such gesture in one, whichever door it is asked for through. diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 6dc67fefa..05afb2e21 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -27,6 +27,7 @@ TAG_GLOBAL_DIALOG_PROJECT_SAVED, TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, ) +from sampletones_application.utils.callbacks.gates import Gate, asking from sampletones_application.utils.file_dialogs.api import ( open_file_dialog, save_file_dialog, @@ -35,6 +36,7 @@ from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome +from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_core.exporters.skipped import SkippedRow from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.backend import ExportBackend @@ -158,26 +160,19 @@ def load_project_safely(self, path: Path) -> None: def guard_close(self, proceed: VoidCallback, decline: VoidCallback) -> None: """Lets the open project close, asking first to save unsaved changes. - With no project open there is nothing to close, so the request is turned away. The - signature is a :data:`Gate`, so the question leads the closing's conversation. + With no project open there is nothing to close, so the request is turned away. The question + reads the project once the screen is free for it. The signature is a :data:`Gate`, so the + question leads the closing's conversation. """ if not self._project_controller.is_open: decline() return - if not self.is_unsaved: - proceed() - return - - self._dialogs.show_save_confirmation( - tag=TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, - title=self._title(GlobalDialogTitleElements.CLOSE_UNSAVED_PROJECT), - message=self._message(GlobalMessageElements.CLOSE_UNSAVED_PROJECT), - on_save=self._write_project, - on_confirm=proceed, - on_cancel=decline, - ok_label=self._label(DialogElements.DISCARD), - ) + self._asking_to_save( + title=GlobalDialogTitleElements.CLOSE_UNSAVED_PROJECT, + message=GlobalMessageElements.CLOSE_UNSAVED_PROJECT, + ok_label=DialogElements.DISCARD, + )(proceed, decline) def close_project(self) -> None: self._project_controller.close() @@ -185,22 +180,38 @@ def close_project(self) -> None: def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: """Lets the exit go on, asking first to save a project with unsaved changes. - Save and Exit both go on, so what the exit asks about next is asked in turn, and Cancel - keeps the application open and turns the exit away. + The question reads the project once the screen is free for it, so a project another + conversation saved or closed meanwhile is asked about no more. Save and Exit both go on, so + what the exit asks about next is asked in turn, and Cancel keeps the application open and + turns the exit away. """ - if not self.is_unsaved: - proceed() - return + self._asking_to_save( + title=GlobalDialogTitleElements.EXIT_CONFIRMATION, + message=GlobalMessageElements.EXIT_UNSAVED_PROJECT, + ok_label=DialogElements.EXIT, + )(proceed, decline) - self._dialogs.show_save_confirmation( - tag=TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, - title=self._title(GlobalDialogTitleElements.EXIT_CONFIRMATION), - message=self._message(GlobalMessageElements.EXIT_UNSAVED_PROJECT), - on_save=self._write_project, - on_confirm=proceed, - on_cancel=decline, - ok_label=self._label(DialogElements.EXIT), - ) + def _asking_to_save( + self, + *, + title: GlobalDialogTitleElements, + message: GlobalMessageElements, + ok_label: DialogElements, + ) -> Gate: + """The gate that offers to save the project's unsaved changes once the screen is free for the question.""" + + def ask(proceed: VoidCallback, decline: VoidCallback) -> None: + self._dialogs.show_save_confirmation( + tag=TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, + title=self._title(title), + message=self._message(message), + on_save=self._write_project, + on_confirm=proceed, + on_cancel=decline, + ok_label=self._label(ok_label), + ) + + return asking(lambda: self.is_unsaved, ask, ModalQueue.when_free) def save(self) -> SaveOutcome: """Saves the project to its current file, prompting for one when it has none, and says so. @@ -411,12 +422,31 @@ def _guard_open( """Lets another project take the open one's place, asking first while one is open. Unsaved changes are offered a save, and an open project holding none is still asked about, - so a project is replaced only by an answer. Cancel turns the request away. + so a project is replaced only by an answer. The question reads the project once the screen + is free for it. Cancel turns the request away. """ - if not self._project_controller.is_open: - proceed() - return + def ask(proceed: VoidCallback, decline: VoidCallback) -> None: + self._ask_before_replacing( + title=title, + message=message, + open_message=open_message, + proceed=proceed, + decline=decline, + ) + + asking(lambda: self._project_controller.is_open, ask, ModalQueue.when_free)(proceed, decline) + + def _ask_before_replacing( + self, + *, + title: GlobalDialogTitleElements, + message: GlobalMessageElements, + open_message: GlobalMessageElements, + proceed: VoidCallback, + decline: VoidCallback, + ) -> None: + """Asks what becomes of the open project: a save of its unsaved changes, or its place alone.""" if self.is_unsaved: self._dialogs.show_save_confirmation( tag=TAG_GLOBAL_DIALOG_PROJECT_UNSAVED, diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index e10ac8768..07a0f8f55 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -1,3 +1,4 @@ +from functools import partial from pathlib import Path from typing import Callable, Optional, Tuple @@ -28,6 +29,7 @@ TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, TAG_GLOBAL_DIALOG_RECONSTRUCTION_SAVED, ) +from sampletones_application.utils.callbacks.gates import asking from sampletones_application.utils.file_dialogs.api import ( open_file_dialog, save_file_dialog, @@ -36,6 +38,7 @@ from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome +from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_core.audio import AudioDeviceManager from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample @@ -248,10 +251,24 @@ def guard_load_converted( A conversion can write over the very file the open document came from, and a save would then write the old document over the new one. The question in that case is whether to discard the changes and load, and Cancel keeps them for a save to another file. Every other - document is asked about the way one opened by hand is. With ``filepath`` bound, the - signature is a :data:`Gate`, so the question leads the loading's conversation. + document is asked about the way one opened by hand is. The question reads the open document + once the screen is free for it. With ``filepath`` bound, the signature is a :data:`Gate`, so + the question leads the loading's conversation. """ - if self._requires_save_confirmation() and self._reconstruction_manager.is_backed_by(filepath): + asking( + self._requires_save_confirmation, + partial(self._ask_before_loading_converted, filepath), + ModalQueue.when_free, + )(proceed, decline) + + def _ask_before_loading_converted( + self, + filepath: Path, + proceed: VoidCallback, + decline: VoidCallback, + ) -> None: + """Asks what becomes of the unsaved changes before the file a conversion wrote loads.""" + if self._reconstruction_manager.is_backed_by(filepath): self._dialogs.show_confirmation( tag=TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED, message=self._language_manager["global.dialog.message.load_replaced_reconstruction"], @@ -262,7 +279,13 @@ def guard_load_converted( ) return - self.guard_load(proceed, decline) + self._offer_save( + title=self._language_manager["global.dialog.title.load_unsaved_reconstruction"], + message=self._language_manager["global.dialog.message.load_unsaved_reconstruction"], + ok_label=self._language_manager["global.dialog.label.discard"], + proceed=proceed, + decline=decline, + ) def load_reconstruction_safely(self, path: Path) -> None: """Loads the persisted reconstruction when the application starts. @@ -565,6 +588,9 @@ def _save_first( ) -> None: """Goes on with ``proceed``, offering first to save a standalone document with unsaved changes. + The offer reads the document once the screen is free for it, so a document another + conversation saved or closed meanwhile goes on with no question. + Args: title: The prompt's title. message: What the prompt says would be lost. @@ -572,10 +598,28 @@ def _save_first( proceed: What runs once the document is saved, or once the reader lets the changes go. decline: What runs once the reader keeps the changes, or once the save fails. """ - if not self._requires_save_confirmation(): - proceed() - return + def ask(proceed: VoidCallback, decline: VoidCallback) -> None: + self._offer_save( + title=title, + message=message, + ok_label=ok_label, + proceed=proceed, + decline=decline, + ) + + asking(self._requires_save_confirmation, ask, ModalQueue.when_free)(proceed, decline) + + def _offer_save( + self, + *, + title: str, + message: str, + ok_label: str, + proceed: VoidCallback, + decline: VoidCallback, + ) -> None: + """Puts the save prompt for the open document to the reader.""" self._dialogs.show_save_confirmation( tag=TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, title=title, diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index e81f294b9..2eb703904 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -63,9 +63,11 @@ from sampletones_application.ui.panels.instruction.waveform import ( GUIInstructionWaveformPanel, ) +from sampletones_application.utils.callbacks.gates import asking from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_application.view_model.instruction.data import InstructionPanelData from sampletones_application.view_model.instruction.details import ( InstructionDetailsPanelViewModel, @@ -506,12 +508,12 @@ def is_library_generating(self) -> bool: def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: """Lets the exit go on, asking first while a library is being built, which exiting stops. - Cancel keeps the library building and turns the exit away. + The question reads the library once the screen is free for it, so a build that ended + meanwhile is asked about no more. Cancel keeps the library building and turns the exit away. """ - if not self.is_library_generating(): - proceed() - return + asking(self.is_library_generating, self._ask_before_exit, ModalQueue.when_free)(proceed, decline) + def _ask_before_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: self._dialogs.show_confirmation( TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, self._language_manager["global.dialog.message.exit_library_generation_in_progress"], diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 3ef3ff403..0d5b4fd5b 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -57,12 +57,14 @@ from sampletones_application.ui.panels.main.converter.panel import GUIConverterPanel from sampletones_application.ui.panels.main.explorer import GUIExplorerPanel from sampletones_application.ui.panels.main.source.panel import GUISourceSettingsPanel +from sampletones_application.utils.callbacks.gates import asking from sampletones_application.utils.file_dialogs.api import select_directory_dialog from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter +from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_application.utils.gui.render_thread import on_render_thread from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.view_model.main.advanced import ( @@ -755,12 +757,12 @@ def is_converter_active(self) -> bool: def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: """Lets the exit go on, asking first while a conversion runs, which exiting stops. - Cancel keeps the conversion running and turns the exit away. + The question reads the converter once the screen is free for it, so a run that ended + meanwhile is asked about no more. Cancel keeps the conversion running and turns the exit away. """ - if not self.is_converter_active(): - proceed() - return + asking(self.is_converter_active, self._ask_before_exit, ModalQueue.when_free)(proceed, decline) + def _ask_before_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: self._dialogs.show_confirmation( TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, self._language_manager["global.dialog.message.exit_conversion_in_progress"], diff --git a/src/sampletones_application/utils/callbacks/gates.py b/src/sampletones_application/utils/callbacks/gates.py index 5931862f3..3f2caf777 100644 --- a/src/sampletones_application/utils/callbacks/gates.py +++ b/src/sampletones_application/utils/callbacks/gates.py @@ -25,6 +25,40 @@ def gate(proceed: VoidCallback, _decline: VoidCallback) -> None: return gate +def asking( + unsettled: Callable[[], bool], + question: Gate, + screen: Wait, +) -> Gate: + """A gate that asks ``question`` about something unsettled once ``screen`` is free for the question. + + While ``unsettled`` reads false, the request goes on at once, whatever holds the screen. Otherwise + the gate waits for the screen and reads ``unsettled`` again there, so the question speaks of the + state it shows over. A thing another conversation settled meanwhile, such as a project its answer + closed, lets the request through with no question. + + Args: + unsettled: Whether there is something to ask about. + question: The gate that asks, which runs only while the screen is free for it. + screen: The wait for the screen, such as the modal line's turn. + """ + + def gate(proceed: VoidCallback, decline: VoidCallback) -> None: + if not unsettled(): + proceed() + return + + def ask_on_the_screen() -> None: + if unsettled(): + question(proceed, decline) + else: + proceed() + + screen(ask_on_the_screen) + + return gate + + def pass_gates( gates: Sequence[Gate], arrive: VoidCallback, diff --git a/src/sampletones_application/utils/gui/modal_queue.py b/src/sampletones_application/utils/gui/modal_queue.py index 0b8135266..1629041db 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, Tuple +from typing import ClassVar, Deque, Dict, List, Optional, Tuple, Union from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_shared.meta import NonInstantiableMeta @@ -20,6 +20,20 @@ class WaitingModal: build: VoidCallback +@dataclass(frozen=True) +class WaitingTurn: + """A gesture waiting for the screen before it reads what it asks about. + + Attributes: + continuation: What runs once the screen is free for it. + """ + + continuation: VoidCallback + + +WaitingEntry = Union[WaitingModal, WaitingTurn] + + @dataclass(frozen=True) class ModalQueueSnapshot: """What the line holds at one moment, for a reader watching the screen from outside it. @@ -28,18 +42,20 @@ class ModalQueueSnapshot: 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. + turns: How many gestures wait in line for the screen before they ask. turning: Whether the line moves on in a coming frame. """ shown: Optional[str] aside: Tuple[str, ...] waiting: Tuple[str, ...] + turns: int 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 + return not self.aside and not self.waiting and not self.turns and not self.turning class ModalQueue(metaclass=NonInstantiableMeta): @@ -51,10 +67,14 @@ class ModalQueue(metaclass=NonInstantiableMeta): The screen therefore belongs to one conversation at a time: a modal window, the modals it hands the screen to while it steps aside, and the ones its answers raise. A modal asked for from - anywhere else, such as the report of a job that finished or a prompt raised by a gesture that - waited, takes its place in line and opens once the conversation holding the screen has ended, - a frame after its last window left. A window asked for again while it waits keeps its place in - line with the newer request. The line is one for the process, as DearPyGui's context is. + anywhere else, such as the report of a job that finished, takes its place in line and opens once + the conversation holding the screen has ended, a frame after its last window left. A window asked + for again while it waits keeps its place in line with the newer request. The line is one for the + process, as DearPyGui's context is. + + A guard with something to ask takes a turn in the line, so it reads what it asks about once the + screen is free for its question. The conversation that held the screen has settled what it + changes by then, such as a project its answer closed. A hand-off is how a conversation keeps the screen while its windows change: what runs a frame after a window stepped aside or left, opening the modal that comes next or bringing the window @@ -65,7 +85,7 @@ class ModalQueue(metaclass=NonInstantiableMeta): _aside: ClassVar[List[str]] = [] _returning: ClassVar[Dict[str, VoidCallback]] = {} _hand_offs: ClassVar[List[VoidCallback]] = [] - _waiting: ClassVar[Deque[WaitingModal]] = deque() + _waiting: ClassVar[Deque[WaitingEntry]] = deque() _clearing: ClassVar[bool] = False _handing_off: ClassVar[bool] = False _turn_due: ClassVar[bool] = False @@ -82,13 +102,22 @@ def open(cls, tag: str, build: VoidCallback) -> None: build: What builds the window. """ cls._forget_waiting(tag) - waiting = WaitingModal(tag=tag, build=build) - if cls._shown is None and not cls._clearing and (cls._handing_off or not cls._screen_taken()): - cls._build(waiting) - elif cls._handing_off: - cls._waiting.appendleft(waiting) - else: - cls._waiting.append(waiting) + cls._admit(WaitingModal(tag=tag, build=build)) + + @classmethod + def when_free(cls, continuation: VoidCallback) -> None: + """Runs ``continuation`` once the screen is free for it, as a turn in the line. + + The turn goes by the rule a modal opens by: at once where the screen is free or a hand-off + carries it, and in its place in line otherwise. Once it has run, the line goes on to the next + window waiting while the screen stays free, so a turn that opens a modal holds the line. The + signature is a :data:`Wait`, so a guard reads what it asks about once its question may reach + the screen. + + Args: + continuation: What runs once the screen is free for it. + """ + cls._admit(WaitingTurn(continuation=continuation)) @classmethod def leave(cls, tag: str) -> None: @@ -145,7 +174,8 @@ def snapshot(cls) -> ModalQueueSnapshot: return ModalQueueSnapshot( shown=cls._shown, aside=tuple(cls._aside), - waiting=tuple(waiting.tag for waiting in cls._waiting), + waiting=tuple(entry.tag for entry in cls._waiting if isinstance(entry, WaitingModal)), + turns=sum(1 for entry in cls._waiting if isinstance(entry, WaitingTurn)), turning=cls._turn_due, ) @@ -167,13 +197,34 @@ def _screen_taken(cls) -> bool: return cls._shown is not None or bool(cls._aside) or bool(cls._hand_offs) or cls._clearing @classmethod - def _build(cls, waiting: WaitingModal) -> None: - cls._shown = waiting.tag - waiting.build() + def _admit(cls, entry: WaitingEntry) -> None: + """Takes ``entry`` now where the screen is free for it, and puts it in line otherwise. + + An entry a hand-off asks for joins the conversation that handed it the screen, ahead of every + entry waiting in line. Any other one waits for the conversation holding the screen to end. + """ + if cls._shown is None and not cls._clearing and (cls._handing_off or not cls._screen_taken()): + cls._take(entry) + elif cls._handing_off: + cls._waiting.appendleft(entry) + else: + cls._waiting.append(entry) + + @classmethod + def _take(cls, entry: WaitingEntry) -> None: + """Gives ``entry`` the screen: a window is built on it, and a turn runs.""" + match entry: + case WaitingModal(tag=tag, build=build): + cls._shown = tag + build() + case WaitingTurn(continuation=continuation): + continuation() @classmethod def _forget_waiting(cls, tag: str) -> None: - cls._waiting = deque(waiting for waiting in cls._waiting if waiting.tag != tag) + cls._waiting = deque( + entry for entry in cls._waiting if not (isinstance(entry, WaitingModal) and entry.tag == tag) + ) @classmethod def _take_a_turn(cls) -> None: @@ -199,8 +250,22 @@ def _turn(cls) -> None: if cls._bring_back(): return - if not cls._screen_taken() and cls._waiting: - cls._build(cls._waiting.popleft()) + cls._move_the_line() + + @classmethod + def _move_the_line(cls) -> None: + """Gives the screen to the entries at the head of the line while it stays free. + + A turn that raises leaves the entries behind it for a coming frame, so the line goes on there. + """ + moved = False + try: + while cls._waiting and not cls._screen_taken(): + cls._take(cls._waiting.popleft()) + moved = True + finally: + if not moved: + cls._take_a_turn() @classmethod def _run_hand_offs(cls) -> None: diff --git a/tests/screens/prompts/closing/test_over_a_question.py b/tests/screens/prompts/closing/test_over_a_question.py index c3bedc2e0..8e5dcbfde 100644 --- a/tests/screens/prompts/closing/test_over_a_question.py +++ b/tests/screens/prompts/closing/test_over_a_question.py @@ -8,7 +8,7 @@ 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.vocabulary.dialogs import EXIT_PROJECT_MESSAGE +from tests.suite.screens.vocabulary.dialogs import CLOSE_PROJECT_MESSAGE, EXIT_PROJECT_MESSAGE from tests.suite.screens.worlds.recordings import BASS, LEAD PROJECT: Final[str] = "Closing.stp" @@ -73,6 +73,46 @@ def close_once_more(screen: Screen) -> None: screen.scenario(change_a_saved_project, close_twice, cancel_leaves_no_question, close_once_more).run() +class TestClosingTheWindowOverTheCloseProjectQuestion: + """Closing the window while Close project asks about the unsaved project leaves once Discard closes it. + + The saved project is retitled and Close project asks. The window close waits behind that question, and + Discard closes the project, which leaves nothing for the exit to ask about: the application stops with + no second question, and the file keeps its saved title. + """ + + def test_discard_leaves_without_a_second_question(self, screen: Screen) -> None: + """The exit reads the project once Discard has closed it, so it asks nothing and leaves.""" + prompt = screen.project.unsaved_prompt + + def close_the_project(screen: Screen) -> None: + screen.project.close() + + screen.expect(prompt.is_shown, bool, description="the question about closing the project") + assert prompt.words() == screen.words(CLOSE_PROJECT_MESSAGE) + + def close_the_window(screen: Screen) -> None: + screen.close_window() + + screen.frames(SETTLING_FRAMES) + assert prompt.words() == screen.words(CLOSE_PROJECT_MESSAGE) + assert len(screen.shown_windows()) == 1 + assert screen.is_running() + + def discard_and_leave(screen: Screen) -> None: + prompt.confirm() + + assert screen.wait_for_exit() + assert saved_project_title(PROJECTS_DIRECTORY / PROJECT) == SAVED_TITLE + + screen.scenario( + change_a_saved_project, + close_the_project, + close_the_window, + discard_and_leave, + ).run() + + class TestClosingTheWindowOverTheReassignQuestion: """Closing the window while Keyboard settings asks to reassign keys asks about the project after the dialog. diff --git a/tests/suite/questions.py b/tests/suite/questions.py new file mode 100644 index 000000000..c3973c413 --- /dev/null +++ b/tests/suite/questions.py @@ -0,0 +1,97 @@ +from dataclasses import dataclass +from typing import Final, List, Optional + +import pytest + +from sampletones_application.utils.callbacks.gates import Gate, asking +from sampletones_application.utils.gui.modal_queue import ModalQueue +from sampletones_shared.types.callback import VoidCallback +from tests.suite.frames import Frames + +STANDING_WINDOW: Final[str] = "standing window" + + +class StandingWindow: + """Another conversation holding the screen, such as a dialog the reader has open.""" + + def __init__(self, frames: Frames) -> None: + self._frames = frames + + def stand(self) -> None: + ModalQueue.open(STANDING_WINDOW, lambda: None) + + def leave(self) -> None: + """Takes the window off the screen and renders the frame the line moves on in.""" + ModalQueue.leave(STANDING_WINDOW) + self._frames.render() + + +@pytest.fixture +def standing_window(held_frames: Frames) -> StandingWindow: + """A window standing on the screen, which a case takes away when it wants the screen free.""" + window = StandingWindow(held_frames) + window.stand() + return window + + +@dataclass(frozen=True) +class StandingQuestion: + """A question on the screen, with the two ways out of it.""" + + tag: str + proceed: VoidCallback + decline: VoidCallback + + +class OnScreenDocument: + """Something unfinished whose owner asks about it on the screen, the way the application's guards do. + + A guard asks once the screen is free for its question, and reads whether the thing is unfinished + then. An answer leaves the screen first and runs a frame later, as a dialog's answer does, so a case + renders a frame between the answer and what it lets through. + """ + + def __init__(self, name: str, asked: List[str]) -> None: + self.name = name + self.unfinished = False + self._asked = asked + self._standing: Optional[StandingQuestion] = None + + def guard(self, question: str) -> Gate: + """The gate that asks ``question`` while the thing is unfinished, and lets the request through otherwise. + + The question is recorded in the shared ``asked`` list as ``" "`` once it reaches + the screen. + """ + + tag = f"{self.name} {question}" + + def ask(proceed: VoidCallback, decline: VoidCallback) -> None: + def build() -> None: + self._asked.append(tag) + self._standing = StandingQuestion(tag=tag, proceed=proceed, decline=decline) + + ModalQueue.open(tag, build) + + return asking(lambda: self.unfinished, ask, ModalQueue.when_free) + + def finish(self) -> None: + """Settles the thing, the way closing a document lets its changes go or a run comes to its end.""" + self.unfinished = False + + def go_on(self) -> None: + """The reader answering the question standing with Save, Discard or Exit.""" + question = self._take_question() + ModalQueue.hand_off(question.proceed) + + def cancel(self) -> None: + """The reader answering the question standing with Cancel.""" + question = self._take_question() + ModalQueue.hand_off(question.decline) + + def _take_question(self) -> StandingQuestion: + """Takes the question standing off the screen, the way an answered dialog leaves before its answer runs.""" + assert self._standing is not None, f"No question about the {self.name} stands" + question, self._standing = self._standing, None + ModalQueue.leave(question.tag) + return question diff --git a/tests/suite/screens/vocabulary/dialogs.py b/tests/suite/screens/vocabulary/dialogs.py index f87b96900..8de61c7a1 100644 --- a/tests/suite/screens/vocabulary/dialogs.py +++ b/tests/suite/screens/vocabulary/dialogs.py @@ -12,3 +12,4 @@ 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" +CLOSE_PROJECT_MESSAGE: Final[str] = "global.dialog.message.close_unsaved_project" diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py index a9ca6ba59..560cb09a5 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py @@ -11,7 +11,11 @@ from sampletones_application.tags.instructions import TAG_INSTRUCTIONS_LIBRARY_DIALOG_REBUILD_CONFIRMATION from sampletones_core.library import LibraryState from sampletones_shared.exceptions import LibraryDisplayError +from tests.suite.frames import held_frames from tests.suite.language import FakeLanguageManager +from tests.suite.questions import StandingWindow, standing_window + +__all__ = ["held_frames", "standing_window"] GENERATION_STATUS_TITLE_KEY: Final[str] = "instructions.library.title.generation_status_dialog" REMOVE_LIBRARY_MESSAGE_KEY: Final[str] = "instructions.library.message.remove_library_message" @@ -346,6 +350,26 @@ def test_an_idle_library_lets_the_exit_go_on(self) -> None: decline.assert_not_called() coordinator._dialogs.show_confirmation.assert_not_called() + def test_a_question_waits_while_another_window_stands(self, standing_window: StandingWindow) -> None: + coordinator = self._coordinator(generating=True) + + coordinator.guard_exit(MagicMock(), MagicMock()) + + coordinator._dialogs.show_confirmation.assert_not_called() + standing_window.leave() + coordinator._dialogs.show_confirmation.assert_called_once() + + def test_a_library_finished_meanwhile_lets_the_exit_go_on(self, standing_window: StandingWindow) -> None: + coordinator = self._coordinator(generating=True) + proceed = MagicMock() + coordinator.guard_exit(proceed, MagicMock()) + + coordinator._library_logic.is_library_generating.return_value = False + standing_window.leave() + + proceed.assert_called_once_with() + coordinator._dialogs.show_confirmation.assert_not_called() + def test_a_library_being_built_asks_first(self) -> None: coordinator = self._coordinator(generating=True) proceed = MagicMock() diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 3c5de1d86..cf750e052 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -21,7 +21,11 @@ from sampletones_core.constants.enums import ChannelName from tests.suite.application import settled from tests.suite.files import LOCKED_FOLDER, held_at, requires_folder_permissions +from tests.suite.frames import held_frames from tests.suite.language import FakeLanguageManager +from tests.suite.questions import StandingWindow, standing_window + +__all__ = ["held_frames", "standing_window"] CONVERTER_RUNNING_MESSAGE_KEY: Final[str] = "main.explorer.message.converter_running_msg" CONVERTER_RUNNING_TITLE_KEY: Final[str] = "main.explorer.title.converter_running_dialog" @@ -606,6 +610,26 @@ def test_an_idle_converter_lets_the_exit_go_on(self) -> None: decline.assert_not_called() coordinator._dialogs.show_confirmation.assert_not_called() + def test_a_question_waits_while_another_window_stands(self, standing_window: StandingWindow) -> None: + coordinator = self._coordinator(active=True) + + coordinator.guard_exit(MagicMock(), MagicMock()) + + coordinator._dialogs.show_confirmation.assert_not_called() + standing_window.leave() + coordinator._dialogs.show_confirmation.assert_called_once() + + def test_a_conversion_ending_meanwhile_lets_the_exit_go_on(self, standing_window: StandingWindow) -> None: + coordinator = self._coordinator(active=True) + proceed = MagicMock() + coordinator.guard_exit(proceed, MagicMock()) + + coordinator._converter_logic.is_active = False + standing_window.leave() + + proceed.assert_called_once_with() + coordinator._dialogs.show_confirmation.assert_not_called() + def test_a_running_conversion_asks_first(self) -> None: coordinator = self._coordinator(active=True) proceed = MagicMock() diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index 9c45012ca..7c452ced5 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -14,6 +14,7 @@ from sampletones_application.paths import LANG_EN from sampletones_application.services.export.kind import ExportKind from sampletones_application.services.export.success import ExportSuccess +from sampletones_application.utils.callbacks.gates import Gate from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome from sampletones_core.exporters.skipped import NO_SKIPPED_ROWS, SkippedRow, SkipReason from sampletones_core.exporters.truncation import EnvelopeTruncation @@ -32,8 +33,12 @@ from sampletones_shared.paths.extensions import EXT_FILE_MODULE from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.frames import held_frames +from tests.suite.questions import StandingWindow, standing_window from tests.suite.silent_rows import MISSING_VOICE_ID, SILENT_CHANNEL +__all__ = ["held_frames", "standing_window"] + ORIGINAL_TITLE: Final[str] = "As saved" EDITED_TITLE: Final[str] = "As edited" @@ -689,3 +694,102 @@ def test_closing_with_no_project_open_is_turned_away( proceed.assert_not_called() decline.assert_called_once_with() project_coordinator._dialogs.show_save_confirmation.assert_not_called() + + +def _close_the_project(controller: MagicMock) -> None: + controller.is_open = False + controller.is_dirty = False + + +def _save_the_project(controller: MagicMock) -> None: + controller.is_dirty = False + + +class TestAQuestionAboutTheProjectWaitsForTheScreen(BaseTestSuite): + """A guard asks about the open project once the screen is free for its question, reading the project then. + + A project another conversation closed or saved meanwhile lets the request through with no question. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + guard: Callable[[ProjectCoordinator], Gate] + settle: Callable[[MagicMock], None] + + test_cases = ( + TestCase(label="new", guard=lambda coordinator: coordinator.guard_new, settle=_close_the_project), + TestCase(label="open", guard=lambda coordinator: coordinator.guard_open, settle=_close_the_project), + TestCase(label="close", guard=lambda coordinator: coordinator.guard_close, settle=_save_the_project), + TestCase(label="exit", guard=lambda coordinator: coordinator.guard_exit, settle=_save_the_project), + ) + + @pytest.fixture(name="unsaved") + def unsaved_fixture(self, project_coordinator: ProjectCoordinator) -> ProjectCoordinator: + project_coordinator._project_controller.is_open = True + project_coordinator._project_controller.is_dirty = True + return project_coordinator + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_question_waits_while_another_window_stands( + self, + test_case: TestCase, + unsaved: ProjectCoordinator, + standing_window: StandingWindow, + ) -> None: + proceed = MagicMock() + decline = MagicMock() + + test_case.guard(unsaved)(proceed, decline) + + unsaved._dialogs.show_save_confirmation.assert_not_called() + proceed.assert_not_called() + decline.assert_not_called() + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_question_asks_once_the_window_leaves( + self, + test_case: TestCase, + unsaved: ProjectCoordinator, + standing_window: StandingWindow, + ) -> None: + proceed = MagicMock() + decline = MagicMock() + test_case.guard(unsaved)(proceed, decline) + + standing_window.leave() + + prompt = unsaved._dialogs.show_save_confirmation.call_args.kwargs + assert prompt["on_confirm"] is proceed + assert prompt["on_cancel"] is decline + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_project_settled_meanwhile_goes_on_with_no_question( + self, + test_case: TestCase, + unsaved: ProjectCoordinator, + standing_window: StandingWindow, + ) -> None: + proceed = MagicMock() + decline = MagicMock() + test_case.guard(unsaved)(proceed, decline) + + test_case.settle(unsaved._project_controller) + standing_window.leave() + + proceed.assert_called_once_with() + decline.assert_not_called() + unsaved._dialogs.show_save_confirmation.assert_not_called() + unsaved._dialogs.show_confirmation.assert_not_called() + + def test_a_saved_project_lets_the_exit_go_on_while_another_window_stands( + self, + project_coordinator: ProjectCoordinator, + standing_window: StandingWindow, + ) -> None: + """Leaving while a dialog stands goes on at once when there is nothing to ask about.""" + project_coordinator._project_controller.is_dirty = False + proceed = MagicMock() + + project_coordinator.guard_exit(proceed, MagicMock()) + + proceed.assert_called_once_with() diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index 103188e3c..ed8fb5018 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -2,7 +2,7 @@ from enum import Enum from functools import partial from pathlib import Path -from typing import Final, List, Optional, Tuple +from typing import Callable, Final, List, Optional, Tuple from unittest.mock import MagicMock, call, patch import pytest @@ -29,7 +29,7 @@ from sampletones_application.services.regeneration.service import RegenerationService from sampletones_application.services.result import ServiceSuccess from sampletones_application.tags.general import TAG_GLOBAL_DIALOG_RECONSTRUCTION_REPLACED -from sampletones_application.utils.callbacks.gates import pass_gates +from sampletones_application.utils.callbacks.gates import Gate, pass_gates from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import SILENT_VOLUME @@ -49,7 +49,9 @@ from tests.suite.application import HeldQueue, held_queue, scheduling, synchronous_executor from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.frames import held_frames from tests.suite.language import FakeLanguageManager +from tests.suite.questions import StandingWindow, standing_window from tests.suite.stems import ( SHARED_CHANNEL, SHARED_OWNERS, @@ -62,7 +64,15 @@ taking_turns_file, ) -__all__ = ["held_queue", "scheduling", "synchronous_executor", "taking_turns", "taking_turns_file"] +__all__ = [ + "held_frames", + "held_queue", + "scheduling", + "standing_window", + "synchronous_executor", + "taking_turns", + "taking_turns_file", +] OPEN_VOICE_ID: Final[str] = "lead-id" HISTORY_BUDGET: Final[int] = 16 @@ -438,6 +448,88 @@ def test_runtime_error_propagates( reconstruction_coordinator._session_manager.set_current_reconstruction.assert_not_called() +def _asked(coordinator: ReconstructionCoordinator) -> bool: + """Whether the coordinator put a question about the open document to the reader.""" + return bool(coordinator._dialogs.show_save_confirmation.called or coordinator._dialogs.show_confirmation.called) + + +class TestAQuestionAboutTheDocumentWaitsForTheScreen(BaseTestSuite): + """A guard asks about the open document once the screen is free for its question, reading it then. + + A document saved or closed meanwhile lets the request through with no question. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + guard: Callable[[ReconstructionCoordinator], Gate] + + test_cases = ( + TestCase(label="load", guard=lambda coordinator: coordinator.guard_load), + TestCase( + label="load_converted", + guard=lambda coordinator: partial(coordinator.guard_load_converted, Path("written.stn")), + ), + TestCase(label="close", guard=lambda coordinator: coordinator.guard_close), + TestCase(label="exit", guard=lambda coordinator: coordinator.guard_exit), + TestCase(label="edit_voice", guard=lambda coordinator: coordinator.guard_edit_voice), + ) + + @pytest.fixture(name="unsaved") + def unsaved_fixture(self) -> ReconstructionCoordinator: + return _gating_coordinator(unsaved=True, embedded=False) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_question_waits_while_another_window_stands( + self, + test_case: TestCase, + unsaved: ReconstructionCoordinator, + standing_window: StandingWindow, + ) -> None: + proceed = MagicMock() + decline = MagicMock() + + test_case.guard(unsaved)(proceed, decline) + + assert not _asked(unsaved) + proceed.assert_not_called() + decline.assert_not_called() + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_question_asks_once_the_window_leaves( + self, + test_case: TestCase, + unsaved: ReconstructionCoordinator, + standing_window: StandingWindow, + ) -> None: + proceed = MagicMock() + decline = MagicMock() + test_case.guard(unsaved)(proceed, decline) + + standing_window.leave() + + assert _asked(unsaved) + proceed.assert_not_called() + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_document_saved_meanwhile_goes_on_with_no_question( + self, + test_case: TestCase, + unsaved: ReconstructionCoordinator, + standing_window: StandingWindow, + ) -> None: + """Save on a question asked about a document closed meanwhile would ask the same question again.""" + proceed = MagicMock() + decline = MagicMock() + test_case.guard(unsaved)(proceed, decline) + + unsaved._reconstruction_manager.session.unsaved_changes = False + standing_window.leave() + + proceed.assert_called_once_with() + decline.assert_not_called() + assert not _asked(unsaved) + + class TestSaveConfirmationGating(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): diff --git a/tests/unit/sampletones_application/services/song_player/test_song_player.py b/tests/unit/sampletones_application/services/song_player/test_song_player.py index 4e90fa04c..c6d63e010 100644 --- a/tests/unit/sampletones_application/services/song_player/test_song_player.py +++ b/tests/unit/sampletones_application/services/song_player/test_song_player.py @@ -17,7 +17,7 @@ ) from sampletones_core.audio.device import CurrentDevice from sampletones_core.project.song_position import SongPosition -from sampletones_shared.exceptions import NoOutputDeviceError +from sampletones_shared.exceptions import NoOutputDeviceError, PlaybackError SAMPLE_RATE: Final[int] = 44100 SPEAKERS: Final[CurrentDevice] = CurrentDevice(device_index=0, name="Speakers", sample_rate=SAMPLE_RATE, host_api=0) @@ -635,16 +635,20 @@ def test_a_failing_write_reports_it_with_the_output_free(self) -> None: assert reported.wait(timeout=WAIT_TIMEOUT) assert ends == [(SongPlaybackError(error=error), False)] - def test_a_stream_the_device_refuses_reports_the_stop_with_the_output_free(self) -> None: + def test_a_stream_the_device_refuses_reports_the_error_with_the_output_free(self) -> None: + """The refusal reaches the listener once, as the error the device manager raised.""" + refusal = PlaybackError("Failed to open audio stream: device busy") audio_device_manager = _make_device_manager() - audio_device_manager.open_output_stream.side_effect = OSError("device busy") + audio_device_manager.open_output_stream.side_effect = refusal service = _make_streaming_service(audio_device_manager) ends, reported = self.record_ends(service) service.start() assert reported.wait(timeout=WAIT_TIMEOUT) - assert ends == [(SongPlaybackStopped(), False)] + assert service._write_thread is not None + service._write_thread.join(timeout=WAIT_TIMEOUT) + assert ends == [(SongPlaybackError(error=refusal), False)] def test_a_stop_reports_no_end(self) -> None: gate = threading.Event() diff --git a/tests/unit/sampletones_application/test_application_exit.py b/tests/unit/sampletones_application/test_application_exit.py index 7acde8ef8..e52fb6ddc 100644 --- a/tests/unit/sampletones_application/test_application_exit.py +++ b/tests/unit/sampletones_application/test_application_exit.py @@ -1,17 +1,29 @@ +from dataclasses import dataclass from typing import Final, Iterator, List, Optional, Tuple from unittest.mock import MagicMock, patch import pytest from sampletones_application.application import Application +from sampletones_application.utils.callbacks.gates import LatestRequestFlight from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_shared.types.callback import VoidCallback +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.frames import Frames, held_frames +from tests.suite.questions import OnScreenDocument + +__all__ = ["held_frames"] PROJECT: Final[str] = "project" RECONSTRUCTION: Final[str] = "reconstruction" CONVERSION: Final[str] = "conversion" LIBRARY: Final[str] = "library" OWNERS: Final[Tuple[str, ...]] = (PROJECT, RECONSTRUCTION, CONVERSION, LIBRARY) +CLOSE: Final[str] = "close" +EXIT: Final[str] = "exit" +JOB_REPORT: Final[str] = "job report" class Owner: @@ -264,6 +276,168 @@ def test_two_closes_while_an_edit_is_on_its_way_ask_once_it_lands(self, exiting: assert exiting.asked == [RECONSTRUCTION] +def run_at_once(gesture: VoidCallback) -> None: + """The edits of the open reconstruction, with none on their way.""" + gesture() + + +class LeavingOverAQuestion: + """An application whose owners ask on the screen, so an exit can be asked for while another question stands. + + The project and the reconstruction each close through a conversation of their own, and closing one + settles its changes. + """ + + def __init__(self) -> None: + self.asked: List[str] = [] + self.documents = {name: OnScreenDocument(name, self.asked) for name in OWNERS} + project = MagicMock() + project.guard_close.side_effect = self.documents[PROJECT].guard(CLOSE) + project.guard_exit.side_effect = self.documents[PROJECT].guard(EXIT) + project.close_project.side_effect = self.documents[PROJECT].finish + reconstruction = MagicMock() + reconstruction.after_edits.side_effect = run_at_once + reconstruction.guard_close.side_effect = self.documents[RECONSTRUCTION].guard(CLOSE) + reconstruction.guard_exit.side_effect = self.documents[RECONSTRUCTION].guard(EXIT) + reconstruction.close.side_effect = self.documents[RECONSTRUCTION].finish + self.application = Application.__new__(Application) + self.application._project_coordinator = project + self.application._reconstruction_coordinator = reconstruction + self.application._main_tab = MagicMock() + self.application._main_tab.guard_exit.side_effect = self.documents[CONVERSION].guard(EXIT) + self.application._instructions_tab = MagicMock() + self.application._instructions_tab.guard_exit.side_effect = self.documents[LIBRARY].guard(EXIT) + self.exit = MagicMock() + self.application._exit_application = self.exit + self.leave = self.application._exit_flight() + self.closing = { + PROJECT: self.application._document_flight(project.guard_close, project.close_project), + RECONSTRUCTION: self.application._document_flight(reconstruction.guard_close, reconstruction.close), + } + + def close(self, name: str) -> LatestRequestFlight[[]]: + return self.closing[name] + + +@pytest.fixture(name="leaving") +def leaving_fixture() -> LeavingOverAQuestion: + return LeavingOverAQuestion() + + +class TestAnExitAskedWhileACloseAsks(BaseTestSuite): + """An exit asked for while the question of a close stands waits for that question's answer, and then asks + about what the answer left. + + The reported case: the project's close asks, the window is closed, and Discard closes the project. The exit + then has nothing left to ask about the project, so the application leaves. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + document: str + + test_cases = ( + TestCase(label="project", document=PROJECT), + TestCase(label="reconstruction", document=RECONSTRUCTION), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_exit_asks_nothing_while_the_close_asks( + self, + test_case: TestCase, + leaving: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + leaving.documents[test_case.document].unfinished = True + leaving.close(test_case.document)() + + leaving.leave() + held_frames.render() + + assert leaving.asked == [f"{test_case.document} {CLOSE}"] + assert ModalQueue.snapshot().turns == 1 + leaving.exit.assert_not_called() + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_discard_leaves_without_a_second_question( + self, + test_case: TestCase, + leaving: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + leaving.documents[test_case.document].unfinished = True + leaving.close(test_case.document)() + leaving.leave() + + leaving.documents[test_case.document].go_on() + held_frames.render() + + assert leaving.asked == [f"{test_case.document} {CLOSE}"] + leaving.exit.assert_called_once_with() + assert ModalQueue.snapshot().is_settled + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_cancel_leaves_the_exit_to_ask_about_the_changes_kept( + self, + test_case: TestCase, + leaving: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + leaving.documents[test_case.document].unfinished = True + leaving.close(test_case.document)() + leaving.leave() + + leaving.documents[test_case.document].cancel() + held_frames.render() + + assert leaving.asked == [f"{test_case.document} {CLOSE}", f"{test_case.document} {EXIT}"] + leaving.exit.assert_not_called() + + +class TestAnExitAskedWhileAWindowStands: + """An exit asked for while another window holds the screen reads what it asks about once the window leaves.""" + + def test_a_conversion_ending_meanwhile_is_asked_about_no_more( + self, + leaving: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + leaving.documents[CONVERSION].unfinished = True + ModalQueue.open(JOB_REPORT, lambda: None) + leaving.leave() + + leaving.documents[CONVERSION].finish() + ModalQueue.leave(JOB_REPORT) + held_frames.render() + + assert leaving.asked == [] + leaving.exit.assert_called_once_with() + + def test_nothing_unfinished_leaves_at_once(self, leaving: LeavingOverAQuestion, held_frames: Frames) -> None: + """Closing the window over a dialog, with nothing to ask about, leaves with the dialog standing.""" + ModalQueue.open(JOB_REPORT, lambda: None) + + leaving.leave() + + leaving.exit.assert_called_once_with() + assert leaving.asked == [] + + def test_a_conversion_still_running_is_asked_about( + self, + leaving: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + leaving.documents[CONVERSION].unfinished = True + ModalQueue.open(JOB_REPORT, lambda: None) + leaving.leave() + + ModalQueue.leave(JOB_REPORT) + held_frames.render() + + assert leaving.asked == [f"{CONVERSION} {EXIT}"] + leaving.exit.assert_not_called() + + class TestTheFrameTheExitIsDecidedIn: """The work waiting on the render thread stays unrun once the exit is decided, so nothing the reader left behind starts after they chose to leave.""" diff --git a/tests/unit/sampletones_application/test_application_gestures.py b/tests/unit/sampletones_application/test_application_gestures.py index a07a5dc06..ee7062ed0 100644 --- a/tests/unit/sampletones_application/test_application_gestures.py +++ b/tests/unit/sampletones_application/test_application_gestures.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from pathlib import Path -from typing import Callable, Final, Tuple +from typing import Callable, Final, List, Tuple from unittest.mock import MagicMock import pytest @@ -12,9 +12,11 @@ from sampletones_shared.types.callback import VoidCallback from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.frames import Frames, held_frames from tests.suite.gates import HeldGate, held_gate +from tests.suite.questions import OnScreenDocument -__all__ = ["held_gate"] +__all__ = ["held_frames", "held_gate"] COLLABORATORS: Final[Tuple[str, ...]] = ( "_project_coordinator", @@ -35,6 +37,9 @@ RECONSTRUCTION_GUARDS: Final[Tuple[str, ...]] = ("guard_load", "guard_close") FIRST_VOICE: Final[str] = "first-voice" SECOND_VOICE: Final[str] = "second-voice" +PROJECT: Final[str] = "project" +CLOSE: Final[str] = "close" +OPEN: Final[str] = "open" def let_through(proceed: VoidCallback, _decline: VoidCallback) -> None: @@ -330,6 +335,79 @@ def test_editing_two_voices_opens_the_second( app._reconstruction_coordinator.open_project_voice.assert_called_once_with(SECOND_VOICE) +class TestAnOpenAskedWhileACloseAsks: + """Opening a project while the question of its close stands asks about the project the answer leaves. + + Discard closes the project, so the opening has nothing to ask and goes on to its file dialog. Cancel + keeps the changes, so the opening asks about them. + """ + + @pytest.fixture + def asked(self) -> List[str]: + return [] + + @pytest.fixture + def project(self, app: Application, asked: List[str]) -> OnScreenDocument: + project = OnScreenDocument(PROJECT, asked) + project.unfinished = True + app._project_coordinator.guard_close.side_effect = project.guard(CLOSE) + app._project_coordinator.guard_open.side_effect = project.guard(OPEN) + app._project_coordinator.close_project.side_effect = project.finish + return project + + @pytest.fixture + def opening_over_the_close( + self, + bindings: ShortcutBindings, + held_gate: HeldGate, + project: OnScreenDocument, + ) -> OnScreenDocument: + """Close project asked for, its question standing, and Open project asked for after it.""" + bindings.close_project() + held_gate.release() + bindings.open_project() + return project + + def test_the_opening_asks_nothing_while_the_close_asks( + self, + app: Application, + opening_over_the_close: OnScreenDocument, + asked: List[str], + held_frames: Frames, + ) -> None: + held_frames.render() + + assert asked == [f"{PROJECT} {CLOSE}"] + app._project_coordinator.open_project.assert_not_called() + + def test_discard_opens_without_a_second_question( + self, + app: Application, + opening_over_the_close: OnScreenDocument, + asked: List[str], + held_frames: Frames, + ) -> None: + opening_over_the_close.go_on() + held_frames.render() + + assert asked == [f"{PROJECT} {CLOSE}"] + app._project_coordinator.close_project.assert_called_once_with() + app._project_coordinator.open_project.assert_called_once_with() + + def test_cancel_leaves_the_opening_to_ask_about_the_changes_kept( + self, + app: Application, + opening_over_the_close: OnScreenDocument, + asked: List[str], + held_frames: Frames, + ) -> None: + opening_over_the_close.cancel() + held_frames.render() + + assert asked == [f"{PROJECT} {CLOSE}", f"{PROJECT} {OPEN}"] + app._project_coordinator.open_project.assert_not_called() + + class TestLoadingWhatARunWroteAsksOnce: """The Converter's Load asks about the file it loads once, however often it is pressed meanwhile. diff --git a/tests/unit/sampletones_application/utils/callbacks/test_gates.py b/tests/unit/sampletones_application/utils/callbacks/test_gates.py index 44ff8f014..173e65453 100644 --- a/tests/unit/sampletones_application/utils/callbacks/test_gates.py +++ b/tests/unit/sampletones_application/utils/callbacks/test_gates.py @@ -9,6 +9,7 @@ Gate, LatestRequestFlight, SingleFlight, + asking, gated, pass_gates, waiting, @@ -164,6 +165,83 @@ def test_a_gated_gesture_runs_with_its_arguments_once_the_wait_ends(self) -> Non assert opened == [Path("song.stp")] +class TestAQuestionAskedOnTheScreen: + """A question waits for the screen, and reads what it asks about once the screen is free for it. + + The thing asked about is a guard's own state: ``unfinished`` decides whether there is anything to ask. + """ + + @pytest.fixture(name="screen") + def screen_fixture(self) -> HeldWait: + return HeldWait() + + @pytest.fixture(name="question") + def question_fixture(self, reached: List[str]) -> Guard: + return Guard("question", reached, unfinished=True) + + @staticmethod + def ask(question: Guard, screen: HeldWait, reached: List[str]) -> None: + pass_gates( + (asking(lambda: question.unfinished, question, screen),), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + def test_nothing_unfinished_goes_on_while_the_screen_is_taken( + self, + question: Guard, + screen: HeldWait, + reached: List[str], + ) -> None: + question.unfinished = False + + self.ask(question, screen, reached) + + assert reached == [ARRIVED] + + def test_a_question_waits_for_the_screen(self, question: Guard, screen: HeldWait, reached: List[str]) -> None: + self.ask(question, screen, reached) + + assert reached == [] + assert not question.is_asking + + def test_a_question_asks_once_the_screen_is_free( + self, + question: Guard, + screen: HeldWait, + reached: List[str], + ) -> None: + self.ask(question, screen, reached) + + screen.release() + + assert reached == ["question"] + assert question.is_asking + + def test_a_thing_settled_while_the_screen_was_taken_asks_nothing( + self, + question: Guard, + screen: HeldWait, + reached: List[str], + ) -> None: + """A project another conversation closed meanwhile lets the request through with no question.""" + self.ask(question, screen, reached) + + question.unfinished = False + screen.release() + + assert reached == [ARRIVED] + assert not question.is_asking + + def test_the_answer_reaches_whoever_asked(self, question: Guard, screen: HeldWait, reached: List[str]) -> None: + self.ask(question, screen, reached) + screen.release() + + question.cancel() + + assert reached == ["question", DECLINED] + + class RaisingOnce: """A gate that fails the first time it is reached and lets every later request through.""" 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 255d9b25b..e691ac0aa 100644 --- a/tests/unit/sampletones_application/utils/gui/test_modal_queue.py +++ b/tests/unit/sampletones_application/utils/gui/test_modal_queue.py @@ -13,6 +13,7 @@ THIRD: Final[str] = "third" DIALOG: Final[str] = "dialog" PROMPT: Final[str] = "prompt" +TURN: Final[str] = "turn" class Screen: @@ -31,6 +32,10 @@ def open(self, tag: str) -> None: def revealer(self, tag: str) -> VoidCallback: return lambda: self.revealed.append(tag) + def take_a_turn(self) -> None: + """A gesture waiting for the screen, which records the moment it reads what it asks about.""" + ModalQueue.when_free(lambda: self.built.append(TURN)) + @pytest.fixture def screen() -> Screen: @@ -256,6 +261,87 @@ def answer() -> None: assert screen.built == [DIALOG, PROMPT, SECOND] +class TestATurnInTheLine: + """A gesture waiting for the screen goes on once the screen is free for it, in its place in the line.""" + + def test_a_turn_runs_at_once_on_a_free_screen(self, screen: Screen) -> None: + screen.take_a_turn() + + assert screen.built == [TURN] + + def test_a_turn_waits_behind_a_standing_conversation(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + + screen.take_a_turn() + held_frames.render() + + assert screen.built == [FIRST] + + def test_a_turn_runs_a_frame_after_the_conversation_ends(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + screen.take_a_turn() + + ModalQueue.leave(FIRST) + assert screen.built == [FIRST] + held_frames.render() + + assert screen.built == [FIRST, TURN] + + def test_a_turn_joins_the_conversation_a_hand_off_carries(self, screen: Screen, held_frames: Frames) -> None: + """An answer that asks for the screen goes on at once, ahead of the modals waiting in line.""" + screen.open(FIRST) + screen.open(SECOND) + ModalQueue.leave(FIRST) + + ModalQueue.hand_off(lambda: ModalQueue.when_free(lambda: screen.open(PROMPT))) + held_frames.render() + + assert screen.built == [FIRST, PROMPT] + + def test_the_line_keeps_its_order_across_turns_and_windows(self, screen: Screen, held_frames: Frames) -> None: + """A turn that opens nothing lets the line go on to the next window in the same frame.""" + screen.open(FIRST) + screen.open(SECOND) + screen.take_a_turn() + screen.open(THIRD) + + ModalQueue.leave(FIRST) + held_frames.render() + assert screen.built == [FIRST, SECOND] + ModalQueue.leave(SECOND) + held_frames.render() + + assert screen.built == [FIRST, SECOND, TURN, THIRD] + + def test_a_turn_that_opens_a_modal_holds_the_line(self, screen: Screen, held_frames: Frames) -> None: + screen.open(FIRST) + ModalQueue.when_free(lambda: screen.open(PROMPT)) + screen.open(SECOND) + + ModalQueue.leave(FIRST) + held_frames.render() + assert screen.built == [FIRST, PROMPT] + ModalQueue.leave(PROMPT) + held_frames.render() + + assert screen.built == [FIRST, PROMPT, SECOND] + + def test_a_turn_that_raises_leaves_the_line_moving(self, screen: Screen, held_frames: Frames) -> None: + def broken() -> None: + raise RuntimeError("the gesture failed") + + screen.open(FIRST) + ModalQueue.when_free(broken) + screen.open(SECOND) + ModalQueue.leave(FIRST) + + with pytest.raises(RuntimeError): + held_frames.render() + held_frames.render() + + assert screen.built == [FIRST, SECOND] + + class TestAHandOffThatRaises: """A hand-off that fails leaves the conversation and the line to go on in a coming frame.""" @@ -333,6 +419,17 @@ def test_the_line_settles_a_frame_after_the_last_window_left( assert ModalQueue.snapshot().is_settled + def test_a_turn_waiting_reads_apart_from_the_windows(self, screen: Screen) -> None: + screen.open(FIRST) + screen.take_a_turn() + screen.open(SECOND) + + snapshot = ModalQueue.snapshot() + + assert snapshot.waiting == (SECOND,) + assert snapshot.turns == 1 + assert not 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) From bd67884e2a5cf4849d68f87ebb4731642390bf7a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 16:29:42 +0200 Subject: [PATCH 61/84] Fixed: a song on a stream the device refuses ending silently, with the refusal now told the way every tab tells it --- docs/development/application/playback.md | 2 +- src/sampletones_application/application.py | 6 +- .../coordinators/playback/failures.py | 8 +++ .../tabs/sequencer/coordinator.py | 3 +- .../services/song_player/service.py | 47 ++++++++------- src/sampletones_core/audio/manager.py | 53 ++++++++++++----- .../application/refusing_output/constants.py | 3 + .../refusing_output/test_playing.py | 4 +- .../refusing_output/test_playing_a_song.py | 58 +++++++++++++++++++ .../coordinators/playback/test_failures.py | 26 +++++++++ .../coordinators/tabs/test_sequencer.py | 15 +++++ .../test_application_playback.py | 18 +++--- .../sampletones_core/audio/test_manager.py | 27 +++++++++ 13 files changed, 213 insertions(+), 57 deletions(-) create mode 100644 tests/screens/application/refusing_output/constants.py create mode 100644 tests/screens/application/refusing_output/test_playing_a_song.py diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md index fe4b1fd5f..36800dbcc 100644 --- a/docs/development/application/playback.md +++ b/docs/development/application/playback.md @@ -34,7 +34,7 @@ A report comes from the thread writing the audio, and the mark is a widget, so e Priority ranks the two kinds and settles every contest for the device. Starting intentional playback preempts a sounding preview, and a preview requested while intentional playback holds the device is declined. -**A sound starts only on a device in force.** Every request, of either kind, asks the device manager for the output before a thread or a stream opens, so a machine offering no output device refuses the request at once with `NoOutputDeviceError`. One presenter, `PlaybackFailurePresenter` (`coordinators/playback/failures.py`), tells the reader what stopped any sound: a missing output reads as a plain notice, and every other failure as an error. +**A sound starts only on a device in force.** Every request, of either kind, asks the device manager for the output before a thread or a stream opens, so a machine offering no output device refuses the request at once with `NoOutputDeviceError`. One presenter, `PlaybackFailurePresenter` (`coordinators/playback/failures.py`), tells the reader what stopped any sound: a missing output reads as a plain notice, and every other failure as an error. A failure on the thread playing the sound, such as a stream the device refused, reads the same whichever source played it, and is told once. ## Engagement diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 8ae497f82..58e10d8e2 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1215,11 +1215,7 @@ def _on_playback_error(self, exception: Exception) -> None: the report raises a dialog, so it crosses to the render thread the way every worker's result does. """ logger.error_with_traceback(exception, "Playback error occurred") - on_render_thread( - self._playback_failures.present, - exception, - message=self.language_manager["global.dialog.message.audio_playback_error"], - ) + on_render_thread(self._playback_failures.present_playing_failure, exception) def _refresh_browsers(self) -> None: """Reads the disk afresh in every browser, so a reconstruction just written stands in each. diff --git a/src/sampletones_application/coordinators/playback/failures.py b/src/sampletones_application/coordinators/playback/failures.py index 2b44f9d1c..3f5c57c78 100644 --- a/src/sampletones_application/coordinators/playback/failures.py +++ b/src/sampletones_application/coordinators/playback/failures.py @@ -47,3 +47,11 @@ def present( ) case _: self._dialogs.show_error(exception, message) + + def present_playing_failure(self, exception: Exception) -> None: + """Shows the reader what stopped a playback on the thread playing it, such as a stream the device refused. + + Every source reports such a failure with the same opening line, so one refusal reads alike on + every tab. + """ + self.present(exception, message=self._language_manager["global.dialog.message.audio_playback_error"]) diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 4b88118fd..7b6cf5221 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -782,7 +782,8 @@ def _on_song_changed(self) -> None: self._sequencer_order_logic.push_order() def _on_player_error(self, error: Exception) -> None: - self._playback_failures.present(error, message=None) + """Shows the reader what stopped the song on the thread playing it, the way every tab shows it.""" + self._playback_failures.present_playing_failure(error) def _on_player_view_changed(self, view_model: SongPlayerViewModel) -> None: """Settles the marks the transport owns, and how far the grid chases the playhead. diff --git a/src/sampletones_application/services/song_player/service.py b/src/sampletones_application/services/song_player/service.py index 1566a31ce..3e6b9c78a 100644 --- a/src/sampletones_application/services/song_player/service.py +++ b/src/sampletones_application/services/song_player/service.py @@ -212,11 +212,15 @@ def _render_loop(self) -> None: self._enqueue_end() def _write_loop(self) -> None: - """Drains the song to the device, hands the stream back, and then reports how the song ended.""" - stream = self._open_stream() - if stream is None: - self._holding_output.clear() - self._emit(SongPlaybackStopped()) + """Drains the song to the device, hands the stream back, and then reports how the song ended. + + This is the writer thread's whole task, so a stream the device refuses ends here as the song's + error, reported once with the output free. + """ + try: + stream = self._open_stream() + except Exception as exception: # pylint: disable=broad-exception-caught + self._end_refused(exception) return ended = True @@ -232,21 +236,24 @@ def _write_loop(self) -> None: if ended: self._emit_terminal() - def _open_stream(self) -> Optional[pyaudio.Stream]: - try: - sample_rate = self._audio_device_manager.sample_rate - stream = self._audio_device_manager.open_output_stream( - sample_rate=sample_rate, - buffer_size=self._audio_device_manager.buffer_size, - release=self.stop, - ) - logger.debug(f"{self.class_name}: audio stream opened at {sample_rate} Hz") - return stream - except Exception as exception: # pylint: disable=broad-exception-caught - logger.error(f"{self.class_name}: failed to open audio stream: {exception}") - self._stop_event.set() - self._wake_buffer() - return None + def _open_stream(self) -> pyaudio.Stream: + sample_rate = self._audio_device_manager.sample_rate + stream = self._audio_device_manager.open_output_stream( + sample_rate=sample_rate, + buffer_size=self._audio_device_manager.buffer_size, + release=self.stop, + ) + logger.debug(f"{self.class_name}: audio stream opened at {sample_rate} Hz") + return stream + + def _end_refused(self, exception: Exception) -> None: + """Ends a song whose stream never opened: the renderer stops, the output goes free, and the error is told.""" + logger.error_with_traceback(exception, f"{self.class_name}: failed to open audio stream") + self._playback_error = exception + self._stop_event.set() + self._wake_buffer() + self._holding_output.clear() + self._emit_terminal() def _drain_to_stream(self, stream: pyaudio.Stream) -> bool: """Hands the buffered rows to the device until the song ends or a stop comes. diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index dae3d5a2a..bb058e20b 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -722,19 +722,15 @@ def _playback_worker( output: The device and rate in force when the playback was asked for. update: If True, invoke position callback during playback. """ - assert self._pyaudio is not None, "PyAudio instance is not initialized" logger.debug(f"Starting playback: device_index={output.device_index}, sample_rate={output.sample_rate}") try: - stream = self._pyaudio.open( - format=FORMAT, - channels=CHANNELS, - rate=output.sample_rate, - output=True, - output_device_index=output.device_index, + stream = self._open_stream( + device_index=output.device_index, + sample_rate=output.sample_rate, + frames_per_buffer=pyaudio.paFramesPerBufferUnspecified, ) - except OSError as exception: + except PlaybackError as playback_error: self._reset(update=update) - playback_error = PlaybackError(f"Failed to open audio stream: {exception}") self.call(self.on_playback_error, playback_error) return @@ -873,7 +869,7 @@ def open_output_stream( release: Winds the caller's writing down; returns once the stream is handed back. Raises: - PlaybackError: If PyAudio is not initialized. + PlaybackError: If PyAudio is not initialized, or the device refuses to open the stream. NoOutputDeviceError: If no output device is in force. """ if self._pyaudio is None: @@ -881,12 +877,9 @@ def open_output_stream( output = self.require_output() self.stop() - stream = self._pyaudio.open( - format=FORMAT, - channels=CHANNELS, - rate=sample_rate, - output=True, - output_device_index=output.device_index, + stream = self._open_stream( + device_index=output.device_index, + sample_rate=sample_rate, frames_per_buffer=buffer_size, ) with self._lock: @@ -894,6 +887,34 @@ def open_output_stream( return stream + def _open_stream( + self, + *, + device_index: int, + sample_rate: int, + frames_per_buffer: int, + ) -> pyaudio.Stream: + """Opens an output stream on the device, naming a refusal the way every playback reports it. + + Both kinds of playback open their stream here, the one this manager plays and the one it hands + out, so a reader told of a refused stream reads the same words whichever source asked. + + Raises: + PlaybackError: If the device refuses to open the stream. + """ + assert self._pyaudio is not None, "PyAudio instance is not initialized" + try: + return self._pyaudio.open( + format=FORMAT, + channels=CHANNELS, + rate=sample_rate, + output=True, + output_device_index=device_index, + frames_per_buffer=frames_per_buffer, + ) + except OSError as exception: + raise PlaybackError(f"Failed to open audio stream: {exception}") from exception + def close_output_stream(self, stream: pyaudio.Stream) -> None: """Take a handed-out stream back and close it. diff --git a/tests/screens/application/refusing_output/constants.py b/tests/screens/application/refusing_output/constants.py new file mode 100644 index 000000000..8faeeeea6 --- /dev/null +++ b/tests/screens/application/refusing_output/constants.py @@ -0,0 +1,3 @@ +from typing import Final + +PLAYBACK_ERROR_MESSAGE: Final[str] = "global.dialog.message.audio_playback_error" diff --git a/tests/screens/application/refusing_output/test_playing.py b/tests/screens/application/refusing_output/test_playing.py index 0adcb5bbb..350562dcf 100644 --- a/tests/screens/application/refusing_output/test_playing.py +++ b/tests/screens/application/refusing_output/test_playing.py @@ -1,10 +1,10 @@ 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 tests.screens.application.refusing_output.constants import PLAYBACK_ERROR_MESSAGE from tests.suite.screens.application.startup import Startup from tests.suite.screens.boundaries.audio import REFUSED_STREAM from tests.suite.screens.screen import Screen @@ -13,8 +13,6 @@ from tests.suite.screens.worlds.home import World from tests.suite.screens.worlds.recordings import PLAYABLE_RECONSTRUCTION, playing_world -PLAYBACK_ERROR_MESSAGE: Final[str] = "global.dialog.message.audio_playback_error" - @pytest.fixture def world() -> World: diff --git a/tests/screens/application/refusing_output/test_playing_a_song.py b/tests/screens/application/refusing_output/test_playing_a_song.py new file mode 100644 index 000000000..a0ea719a3 --- /dev/null +++ b/tests/screens/application/refusing_output/test_playing_a_song.py @@ -0,0 +1,58 @@ +import operator + +import pytest + +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.application.refusing_output.constants import PLAYBACK_ERROR_MESSAGE +from tests.suite.screens.application.startup import Startup +from tests.suite.screens.boundaries.audio import REFUSED_STREAM +from tests.suite.screens.screen import Screen +from tests.suite.screens.vocabulary.playback import PLAY +from tests.suite.screens.worlds.home import World +from tests.suite.screens.worlds.songs import ARRANGED_PROJECT, sequencer_world + + +@pytest.fixture +def world() -> World: + """A home holding a project with a song to play.""" + return sequencer_world() + + +@pytest.fixture +def startup() -> Startup: + """The application opens on the project with the song.""" + return Startup(reconstruction=None, project=ARRANGED_PROJECT) + + +class TestPlayingASongOnADeviceThatRefusesTheStream: + """A song on a stream the device refuses is reported the way the Reconstructions tab reports it. + + The song opens its stream on a thread of its own, so the report crosses to the render thread before the + error window opens. Play brings up the error with its words, and the refusal is logged once; once OK + dismisses it, the Playback menu offers Play and a second Play reports the refusal again. + """ + + def test_play_reports_the_refusal_each_time(self, screen: Screen) -> None: + notice = screen.error_notice + + def play_reports_it(screen: Screen) -> None: + screen.press_shortcut(ShortcutId.PLAY) + + screen.expect(notice.is_shown, bool, description="the error about the refused stream") + screen.claim_error(REFUSED_STREAM) + assert screen.words(PLAYBACK_ERROR_MESSAGE) in notice.words() + + notice.dismiss() + + screen.expect(notice.is_shown, operator.not_, description="the error dismissed") + screen.expect( + screen.sequencer.playback.play_entry, + screen.words(PLAY).__eq__, + description="the Playback menu offering Play", + ) + + def bring_the_song_forward(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + + screen.scenario(bring_the_song_forward, play_reports_it, play_reports_it).run() diff --git a/tests/unit/sampletones_application/coordinators/playback/test_failures.py b/tests/unit/sampletones_application/coordinators/playback/test_failures.py index 0102d220f..1eeb5b7f8 100644 --- a/tests/unit/sampletones_application/coordinators/playback/test_failures.py +++ b/tests/unit/sampletones_application/coordinators/playback/test_failures.py @@ -13,6 +13,7 @@ NO_OUTPUT_MESSAGE: Final[str] = "global.dialog.message.no_audio_output" NO_OUTPUT_TITLE: Final[str] = "global.dialog.title.no_audio_output" ERROR_MESSAGE: Final[str] = "playback failed" +PLAYING_FAILURE_MESSAGE: Final[str] = "global.dialog.message.audio_playback_error" @pytest.fixture(name="dialogs") @@ -64,3 +65,28 @@ def test_a_failure_reads_as_an_error( dialogs.show_error.assert_called_once_with(exception, ERROR_MESSAGE) dialogs.show_info.assert_not_called() + + +class TestAFailureWhilePlaying: + """A playback that fails on the thread playing it reads the same whichever source played it.""" + + def test_a_refused_stream_reads_as_an_error_with_the_playback_line( + self, + presenter: PlaybackFailurePresenter, + dialogs: MagicMock, + ) -> None: + refusal = PlaybackError("Failed to open audio stream: device busy") + + presenter.present_playing_failure(refusal) + + dialogs.show_error.assert_called_once_with(refusal, LANGUAGE_MANAGER[PLAYING_FAILURE_MESSAGE]) + + def test_a_missing_output_still_reads_as_a_notice( + self, + presenter: PlaybackFailurePresenter, + dialogs: MagicMock, + ) -> None: + presenter.present_playing_failure(NoOutputDeviceError("no device")) + + dialogs.show_info.assert_called_once() + dialogs.show_error.assert_not_called() diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index ab499e90f..093ccf018 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -10,6 +10,7 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.constants.playback import FollowMode from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.coordinators.playback.failures import PlaybackFailurePresenter from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.tabs.sequencer import voices as voices_module from sampletones_application.coordinators.tabs.sequencer.blocks import SequencerBlocks @@ -70,6 +71,7 @@ InvalidReconstructionValuesError, MalformedInstrumentError, NoOutputDeviceError, + PlaybackError, ) from tests.suite.gates import HeldGate, held_gate from tests.suite.language import FakeLanguageManager @@ -658,6 +660,19 @@ def playback_coordinator() -> SequencerTabCoordinator: return instance +class TestASongThatFailsWhilePlaying: + """A song that fails on the thread playing it reads the way every playback failing there reads.""" + + def test_a_refused_stream_is_presented_as_a_failure_while_playing(self) -> None: + coordinator = object.__new__(SequencerTabCoordinator) + coordinator._playback_failures = MagicMock(spec=PlaybackFailurePresenter) + refusal = PlaybackError("Failed to open audio stream: device busy") + + coordinator._on_player_error(refusal) + + coordinator._playback_failures.present_playing_failure.assert_called_once_with(refusal) + + class TestFollowMode: @pytest.mark.parametrize("mode", list(FollowMode), ids=str) def test_the_sounding_frame_is_shown_while_the_mode_follows_patterns( diff --git a/tests/unit/sampletones_application/test_application_playback.py b/tests/unit/sampletones_application/test_application_playback.py index 9df764804..3377d4c08 100644 --- a/tests/unit/sampletones_application/test_application_playback.py +++ b/tests/unit/sampletones_application/test_application_playback.py @@ -1,5 +1,5 @@ import threading -from typing import Final, Iterator, List, Optional, Tuple +from typing import Iterator, List, Tuple from unittest.mock import MagicMock import pytest @@ -9,19 +9,16 @@ from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.gui.render_thread import claim_render_thread, release_render_thread from sampletones_shared.exceptions import PlaybackError -from tests.suite.language import FakeLanguageManager - -FAILURE_MESSAGE_KEY: Final[str] = "global.dialog.message.audio_playback_error" class _PresenterRecorder: """Notes the thread each failure is presented on, which is where its dialog is built.""" def __init__(self) -> None: - self.presented: List[Tuple[int, Exception, Optional[str]]] = [] + self.presented: List[Tuple[int, Exception]] = [] - def present(self, exception: Exception, *, message: Optional[str]) -> None: - self.presented.append((threading.get_ident(), exception, message)) + def present_playing_failure(self, exception: Exception) -> None: + self.presented.append((threading.get_ident(), exception)) @pytest.fixture(name="presenter") @@ -33,8 +30,7 @@ def presenter_fixture() -> _PresenterRecorder: def application_fixture(presenter: _PresenterRecorder) -> Application: application = Application.__new__(Application) application._playback_failures = MagicMock(spec=PlaybackFailurePresenter) - application._playback_failures.present.side_effect = presenter.present - application.language_manager = FakeLanguageManager() + application._playback_failures.present_playing_failure.side_effect = presenter.present_playing_failure return application @@ -66,7 +62,7 @@ def test_a_report_from_the_playing_thread_waits_for_the_render_thread( assert presenter.presented == [] CallbackQueue.process(1.0) - assert presenter.presented == [(threading.get_ident(), failure, FAILURE_MESSAGE_KEY)] + assert presenter.presented == [(threading.get_ident(), failure)] @pytest.mark.usefixtures("drawing") def test_a_report_on_the_render_thread_is_presented_at_once( @@ -78,4 +74,4 @@ def test_a_report_on_the_render_thread_is_presented_at_once( application._on_playback_error(failure) - assert presenter.presented == [(threading.get_ident(), failure, FAILURE_MESSAGE_KEY)] + assert presenter.presented == [(threading.get_ident(), failure)] diff --git a/tests/unit/sampletones_core/audio/test_manager.py b/tests/unit/sampletones_core/audio/test_manager.py index c9607a377..a7565aa81 100644 --- a/tests/unit/sampletones_core/audio/test_manager.py +++ b/tests/unit/sampletones_core/audio/test_manager.py @@ -217,6 +217,33 @@ def test_the_playback_reads_stopped_when_the_refusal_is_reported(self) -> None: assert playing_when_reported == [False] manager._position_callback.assert_called_once_with(0) + def test_a_handed_out_stream_refused_raises_a_playback_error(self) -> None: + manager = _manager() + manager.stop = MagicMock() + manager._pyaudio.open.side_effect = OSError("the device is busy") + + with pytest.raises(PlaybackError, match="the device is busy"): + manager.open_output_stream(sample_rate=DEFAULT_SAMPLE_RATE, buffer_size=800, release=MagicMock()) + + assert manager._stream_owners == {} + + def test_both_kinds_of_playback_name_the_refusal_alike(self) -> None: + """A refused stream reads the same whether the manager plays the audio or hands the stream out.""" + manager = _manager() + manager.stop = MagicMock() + manager._pyaudio.open.side_effect = OSError("the device is busy") + manager._position_callback = None + reported: List[Exception] = [] + manager.on_playback_error = reported.append + manager._playing = True + manager._audio_data = np.zeros(TestPlaybackStart.AUDIO_LENGTH, dtype=np.float32) + manager._playback_worker(output=manager.require_output(), update=False) + + with pytest.raises(PlaybackError) as raised: + manager.open_output_stream(sample_rate=DEFAULT_SAMPLE_RATE, buffer_size=800, release=MagicMock()) + + assert [str(error) for error in reported] == [str(raised.value)] + class TestSeekingAPlayback(BaseTestSuite): """A seek moves the playback of the owner asking for it, clamped to the audio, under one lock.""" From 4a916e3ac3455c663764233e634f7e91287adca8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 16:32:33 +0200 Subject: [PATCH 62/84] Fixed: a playback worker outliving its stop wiping or doubling the playback that replaced it --- src/sampletones_core/audio/manager.py | 70 ++++++++++++--- .../sampletones_core/audio/test_manager.py | 85 ++++++++++++++++++- 2 files changed, 139 insertions(+), 16 deletions(-) diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index bb058e20b..5ebc0e436 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -91,6 +91,7 @@ def __init__(self) -> None: self._active_priority: int = 0 self._paused: bool = False self._stop: bool = False + self._generation: int = 0 self._playback_thread: Optional[threading.Thread] = None self._stream_owners: Dict[pyaudio.Stream, VoidCallback] = {} @@ -629,11 +630,13 @@ def play( self._active_priority = priority self._paused = False self._stop = False + self._generation += 1 + generation = self._generation self._resume_event.set() self._playback_thread = threading.Thread( target=self._playback_worker, - kwargs={"output": output, "update": update}, + kwargs={"output": output, "update": update, "generation": generation}, daemon=True, name="AudioPlaybackWorker", ) @@ -670,22 +673,29 @@ def replace_audio( self._position = min(self._position, len(self._audio_data)) return True - def _playback_loop(self, stream: pyaudio.Stream, update: bool) -> None: + def _playback_loop( + self, + stream: pyaudio.Stream, + update: bool, + generation: int, + ) -> None: """ Internal audio playback loop. Continuously reads audio chunks and writes them to the stream until stopped, - paused, or audio ends. Respects pause state and stop flag. + paused, or audio ends. Respects pause state and stop flag, and ends once a newer + playback has taken the generation over. Args: stream: PyAudio stream to write audio data to. update: If True, invoke position callback after each chunk. + generation: The playback this loop plays. """ while True: self._resume_event.wait(timeout=0.1) with self._lock: - if self._stop or self._audio_data is None: + if self._stop or self._audio_data is None or generation != self._generation: break if self._paused: @@ -710,6 +720,7 @@ def _playback_worker( *, output: CurrentDevice, update: bool, + generation: int, ) -> None: """ Playback thread worker function. @@ -718,9 +729,15 @@ def _playback_worker( refuses to open leaves the playback idle, as a finished one does, before the error callback reports it, so whoever follows the playback reads it stopped. + A stop waits a while for the worker and then lets it go, so a device slow to open can bring + the worker back after a newer play has begun. The worker acts for its own ``generation`` + alone: a refusal it meets then is left unreported, a stream it opens then closes unplayed, + and the newer playback stands as it was. + Args: output: The device and rate in force when the playback was asked for. update: If True, invoke position callback during playback. + generation: The playback this worker plays, stamped by :meth:`play`. """ logger.debug(f"Starting playback: device_index={output.device_index}, sample_rate={output.sample_rate}") try: @@ -730,16 +747,36 @@ def _playback_worker( frames_per_buffer=pyaudio.paFramesPerBufferUnspecified, ) except PlaybackError as playback_error: - self._reset(update=update) - self.call(self.on_playback_error, playback_error) + if self._reset_generation(generation, update=update): + self.call(self.on_playback_error, playback_error) return try: - self._playback_loop(stream, update) + if self._is_current(generation): + self._playback_loop(stream, update, generation) finally: stream.stop_stream() stream.close() - self._reset(update=update) + self._reset_generation(generation, update=update) + + def _is_current(self, generation: int) -> bool: + with self._lock: + return generation == self._generation + + def _reset_generation(self, generation: int, *, update: bool) -> bool: + """Resets playback state to idle while ``generation`` is the playback in force. + + Returns: + bool: Whether ``generation`` was in force, and the state was reset. + """ + with self._lock: + if generation != self._generation: + return False + + self._clear_playback() + + self._report_the_start(update=update) + return True def _reset(self, *, update: bool = True) -> None: """ @@ -752,12 +789,19 @@ def _reset(self, *, update: bool = True) -> None: update: If True, invoke position callback with 0. """ with self._lock: - self._playing = False - self._paused = False - self._position = 0 - self._audio_data = None - self._output_owner = None + self._clear_playback() + + self._report_the_start(update=update) + + def _clear_playback(self) -> None: + """Puts the playback state at idle. The caller holds the lock.""" + self._playing = False + self._paused = False + self._position = 0 + self._audio_data = None + self._output_owner = None + def _report_the_start(self, *, update: bool) -> None: if update and self._position_callback is not None: self.call(self._position_callback, 0) diff --git a/tests/unit/sampletones_core/audio/test_manager.py b/tests/unit/sampletones_core/audio/test_manager.py index a7565aa81..2b7ccf8f2 100644 --- a/tests/unit/sampletones_core/audio/test_manager.py +++ b/tests/unit/sampletones_core/audio/test_manager.py @@ -1,7 +1,7 @@ import threading from dataclasses import dataclass from pathlib import Path -from typing import Callable, Dict, Final, Iterator, List, Union +from typing import Any, Callable, Dict, Final, Iterator, List, Tuple, Union from unittest.mock import MagicMock, patch import numpy as np @@ -55,6 +55,7 @@ def _manager() -> AudioDeviceManager: manager._resume_event = threading.Event() manager._playing = False manager._active_priority = 0 + manager._generation = 0 manager._stream_owners = {} manager.on_acquire_output = None manager.external_output_priority = None @@ -212,7 +213,7 @@ def test_the_playback_reads_stopped_when_the_refusal_is_reported(self) -> None: manager._playing = True manager._audio_data = np.zeros(TestPlaybackStart.AUDIO_LENGTH, dtype=np.float32) - manager._playback_worker(output=manager.require_output(), update=True) + manager._playback_worker(output=manager.require_output(), update=True, generation=manager._generation) assert playing_when_reported == [False] manager._position_callback.assert_called_once_with(0) @@ -237,7 +238,7 @@ def test_both_kinds_of_playback_name_the_refusal_alike(self) -> None: manager.on_playback_error = reported.append manager._playing = True manager._audio_data = np.zeros(TestPlaybackStart.AUDIO_LENGTH, dtype=np.float32) - manager._playback_worker(output=manager.require_output(), update=False) + manager._playback_worker(output=manager.require_output(), update=False, generation=manager._generation) with pytest.raises(PlaybackError) as raised: manager.open_output_stream(sample_rate=DEFAULT_SAMPLE_RATE, buffer_size=800, release=MagicMock()) @@ -245,6 +246,84 @@ def test_both_kinds_of_playback_name_the_refusal_alike(self) -> None: assert [str(error) for error in reported] == [str(raised.value)] +class TestAPlaybackANewerOneReplaced: + """A worker whose stream opens only after a newer play leaves the newer playback as it stands. + + Stopping waits a while for the worker, and a device slow to open can keep it longer, so the older + worker can come back after the newer play has begun. Its refusal is the older playback's alone, and a + stream it opened late closes without a sound. + """ + + FIRST_LENGTH: Final[int] = 8 + SECOND_LENGTH: Final[int] = 16 + + @pytest.fixture(name="replaced") + def replaced_fixture(self) -> Tuple[AudioDeviceManager, Dict[str, Any]]: + """A manager playing a second buffer, and the arguments the worker of the first one was started with.""" + manager = _manager() + manager._buffer_size = self.FIRST_LENGTH + manager._position_callback = MagicMock() + manager._playback_thread = None + with patch("sampletones_core.audio.manager.threading.Thread") as thread: + manager.play(np.zeros(self.FIRST_LENGTH, dtype=np.float32)) + manager.play(np.ones(self.SECOND_LENGTH, dtype=np.float32)) + + manager._position_callback.reset_mock() + return manager, thread.call_args_list[0].kwargs["kwargs"] + + @staticmethod + def assert_the_newer_playback_stands(manager: AudioDeviceManager) -> None: + assert manager.is_playing() + assert manager._audio_data is not None + assert len(manager._audio_data) == TestAPlaybackANewerOneReplaced.SECOND_LENGTH + manager._position_callback.assert_not_called() + + def test_an_older_worker_refused_late_leaves_the_newer_playback_alone( + self, + replaced: Tuple[AudioDeviceManager, Dict[str, Any]], + ) -> None: + manager, first_worker = replaced + manager._pyaudio.open.side_effect = OSError("the device is busy") + reported: List[Exception] = [] + manager.on_playback_error = reported.append + + manager._playback_worker(**first_worker) + + self.assert_the_newer_playback_stands(manager) + assert reported == [] + + def test_an_older_worker_opened_late_closes_its_stream_unplayed( + self, + replaced: Tuple[AudioDeviceManager, Dict[str, Any]], + ) -> None: + manager, first_worker = replaced + stream = MagicMock() + manager._pyaudio.open.return_value = stream + + manager._playback_worker(**first_worker) + + stream.write.assert_not_called() + stream.close.assert_called_once_with() + self.assert_the_newer_playback_stands(manager) + + def test_the_newer_worker_refused_reports_it( + self, + replaced: Tuple[AudioDeviceManager, Dict[str, Any]], + ) -> None: + """The refusal of the playback in force still reaches the reader.""" + manager, _ = replaced + manager._pyaudio.open.side_effect = OSError("the device is busy") + reported: List[Exception] = [] + manager.on_playback_error = reported.append + with patch("sampletones_core.audio.manager.threading.Thread") as thread: + manager.play(np.ones(self.SECOND_LENGTH, dtype=np.float32)) + + manager._playback_worker(**thread.call_args.kwargs["kwargs"]) + + assert len(reported) == 1 + assert not manager.is_playing() + + class TestSeekingAPlayback(BaseTestSuite): """A seek moves the playback of the owner asking for it, clamped to the audio, under one lock.""" From ed5714127052d674346d33d72fdd412dce8c235d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 16:33:15 +0200 Subject: [PATCH 63/84] Removed: an exit case that exercised the callback queue alone, which the queue's own cases cover --- .../unit/sampletones_application/test_application_exit.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/unit/sampletones_application/test_application_exit.py b/tests/unit/sampletones_application/test_application_exit.py index e52fb6ddc..570a153dd 100644 --- a/tests/unit/sampletones_application/test_application_exit.py +++ b/tests/unit/sampletones_application/test_application_exit.py @@ -460,11 +460,3 @@ def test_the_work_due_in_that_frame_stays_unrun(self) -> None: stop_dearpygui.assert_called_once_with() assert not ran - - def test_the_work_due_before_the_exit_runs(self) -> None: - ran: List[str] = [] - CallbackQueue.add(lambda: ran.append("due")) - - CallbackQueue.process(budget_seconds=1.0) - - assert ran == ["due"] From db48291983437084cd975c367ef1004265c0cbfc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 16:33:33 +0200 Subject: [PATCH 64/84] Fixed: the playback ownership table naming the guarded player as the owner of what a failure shows --- docs/development/application/playback.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md index 36800dbcc..98c430087 100644 --- a/docs/development/application/playback.md +++ b/docs/development/application/playback.md @@ -138,7 +138,8 @@ The device holds a release per stream it handed out and invokes it whenever it n | The ranking that settles a contest for the device | `PlaybackPriority` (`logic/shared/`) | | The verbs, target resolution, and the registry of sources | `coordinators/playback/router.py` | | A source's engagement reporting | the transport's player protocol, implemented per source | -| Error presentation for a source's failures | `GuardedPlayer` (`coordinators/playback/guard.py`) | +| What the reader is told when a sound fails | `PlaybackFailurePresenter` (`coordinators/playback/failures.py`) | +| The boundary that hands a transport command's failure to the presenter | `GuardedPlayer` (`coordinators/playback/guard.py`) | | The sequencer's mute set, its mask, and solo | `SequencerChannelsLogic` (`logic/sequencer/channels.py`) | | Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) | | The values a note starts from and a channel holds between frames | `ChannelPerformance` (`sampletones_core/performance/state.py`) | From 1e219938a32209275f31c054db4c07d462f2bd83 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 16:38:39 +0200 Subject: [PATCH 65/84] Fixed: screen scenario homes stranded by a locked folder or a crashed worker, a hidden temporary folder, and the machine's sound reaching a run --- docs/development/application/screens.md | 20 ++++-- tests/suite/screens/boundaries/audio.py | 30 ++++++-- tests/suite/screens/environment.py | 35 ++++++++-- tests/suite/screens/homes.py | 92 +++++++++++++++++++++++++ tests/suite/screens/paths.py | 2 + tests/suite/screens/plugin/hooks.py | 12 ++-- 6 files changed, 168 insertions(+), 23 deletions(-) create mode 100644 tests/suite/screens/homes.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index e665063cf..be8c6eb99 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -180,8 +180,10 @@ how a menu popup's width is measured. - **Audio.** The default output device plays into silence in real time, so playback runs and nothing is heard. It is the one device the application finds on every machine: the scenario's ALSA reads a configuration of its own, which keeps the machine's sound cards and sound server outside the run, and a - clock returns each write and each stop when a device would. A scenario can start on a machine offering - no device at all. + clock returns each write and each stop when a device would. JACK looks for a server no one runs and + starts none, so a JACK server on the machine stays outside the run too. A scenario can start on a + machine offering no device at all, or on one whose device refuses every stream, with the same + configuration in place. - **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, and so do the stand-ins below. @@ -300,10 +302,16 @@ SAMPLETONES_SCREENS_DISPLAY=xephyr uv run python -m pytest tests/screens --no-co A scenario's home is scratch: it is built from the scenario's world in a temporary folder made for each worker, whose path holds no hidden folder, so the application's browsers reach it from a worktree under -`.worktrees/` too, and it goes once the scenario ends. What a run keeps lies under `build/screens/`, in a -folder named after the test: the reports, and when the scenario failed, a screenshot of the last frame and -a copy of the home it left. `screen.capture` keeps a picture as evidence of a look, for a pull request -rather than an assertion. +`.worktrees/` too, and it goes once the scenario ends. A temporary folder inside a hidden folder is +refused, and `TMPDIR` points the run at another. The worker's folder is named after its process, and a +run's first scenario removes the folders whose process has gone, such as a crashed worker's, and leaves +another live run's alone. A folder a scenario left locked opens again before its home is copied and +removed. + +What a run keeps lies under `build/screens/`, in a folder named after the test: the reports, and when the +scenario failed, a screenshot of the last frame and a copy of the home it left. A copy that lost files +leaves a note beside it. `screen.capture` keeps a picture as evidence of a look, for a pull request rather +than an assertion. ## Who governs what diff --git a/tests/suite/screens/boundaries/audio.py b/tests/suite/screens/boundaries/audio.py index 7e2255cc0..95e1bc61c 100644 --- a/tests/suite/screens/boundaries/audio.py +++ b/tests/suite/screens/boundaries/audio.py @@ -14,6 +14,10 @@ DEVICE_BUFFER_SECONDS: Final[float] = 0.05 SILENT_DEFAULT_DEVICE: Final[str] = "pcm.!default {\n type null\n}\n" REFUSED_STREAM: Final[str] = "A screen scenario's device refuses every stream" +JACK_SERVER_VARIABLE: Final[str] = "JACK_DEFAULT_SERVER" +JACK_NO_START_VARIABLE: Final[str] = "JACK_NO_START_SERVER" +ABSENT_JACK_SERVER: Final[str] = "sampletones-screens-absent" +JACK_STAYS_DOWN: Final[str] = "1" class OutputDevice(StrEnum): @@ -101,12 +105,15 @@ class SilentOutputDevice: def __init__(self) -> None: self._clocks: WeakKeyDictionary[pyaudio.PyAudio.Stream, DeviceClock] = WeakKeyDictionary() - def install(self, home: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Points ALSA at the null sink and puts a clock behind every stream before the application starts.""" + @staticmethod + def configure(home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Points the scenario's ALSA at a configuration of its own, whose one device is the null sink.""" configuration = home / ALSA_CONFIGURATION_FILE configuration.write_text(SILENT_DEFAULT_DEVICE, encoding="utf-8") monkeypatch.setenv(ALSA_CONFIGURATION_VARIABLE, str(configuration)) + def keep_time(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Puts a clock behind every stream before the application starts, so the null sink plays in real time.""" open_stream = pyaudio.PyAudio.open write = pyaudio.PyAudio.Stream.write stop_stream = pyaudio.PyAudio.Stream.stop_stream @@ -147,17 +154,30 @@ def provide_output_device( home: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Gives the application the output ``device`` before it starts.""" + """Gives the application the output ``device`` before it starts, with the machine's own sound kept out. + + Whatever the device, the scenario's ALSA reads its own configuration and JACK looks for a server no + one runs, so neither the sound cards nor a sound server running on the machine reach the run. + """ + silent = SilentOutputDevice() + silent.configure(home, monkeypatch) + _away_from_jack(monkeypatch) match device: case OutputDevice.SILENT: - SilentOutputDevice().install(home, monkeypatch) + silent.keep_time(monkeypatch) case OutputDevice.REFUSING: - SilentOutputDevice().install(home, monkeypatch) + silent.keep_time(monkeypatch) _refusing_output_device(monkeypatch) case OutputDevice.NONE: _no_output_device(monkeypatch) +def _away_from_jack(monkeypatch: pytest.MonkeyPatch) -> None: + """Points JACK at a server no one runs, and keeps it from starting one.""" + monkeypatch.setenv(JACK_SERVER_VARIABLE, ABSENT_JACK_SERVER) + monkeypatch.setenv(JACK_NO_START_VARIABLE, JACK_STAYS_DOWN) + + def _refusing_output_device(monkeypatch: pytest.MonkeyPatch) -> None: """Offers the silent device and refuses every stream opened on it, as a device another program holds.""" monkeypatch.setattr(pyaudio.PyAudio, "open", _refused_stream) diff --git a/tests/suite/screens/environment.py b/tests/suite/screens/environment.py index 82c1f4e67..9ad170824 100644 --- a/tests/suite/screens/environment.py +++ b/tests/suite/screens/environment.py @@ -6,7 +6,14 @@ 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 +from tests.suite.screens.homes import let_go, reopen_folders +from tests.suite.screens.paths import ( + ARTIFACTS_DIRECTORY, + HOME_COPY_NOTE, + HOME_FOLDER, + NO_BUS_FILE, + REPORTS_FILE, +) DISPLAY_BACKEND_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_DISPLAY" ARTIFACTS_VARIABLE: Final[str] = "SAMPLETONES_SCREENS_ARTIFACTS" @@ -32,7 +39,7 @@ class ScenarioFolders: The home is scratch, built afresh from the scenario's world in a temporary folder whose path holds no hidden folder, so the application's browsers reach it from any checkout. What the run keeps lies under the run's artifacts in the checkout: the reports, a screenshot of a failure, and a copy of the home a - failed scenario left. + failed scenario left, with a note where that copy lost files. Attributes: root: The scenario's folder under the run's artifacts, kept after the run for a reader. @@ -68,11 +75,27 @@ def prepare(self) -> None: self.home.mkdir(parents=True) def finish(self, *, failed: bool) -> None: - """Lets the scratch home go, keeping a copy among the artifacts where the scenario failed.""" - if failed: - shutil.copytree(self.home, self.root / HOME_FOLDER, symlinks=True) + """Lets the scratch home go, keeping a copy among the artifacts where the scenario failed. - shutil.rmtree(self.home.parent, ignore_errors=True) + A scenario can leave a folder of its home locked, so every folder opens again first. The copy + keeps what it can read, a note beside the artifacts says what it lost, and the home goes + however the copy ended. + """ + reopen_folders(self.home.parent) + try: + if failed: + self._keep_the_home() + finally: + let_go(self.home.parent) + + def _keep_the_home(self) -> None: + try: + shutil.copytree(self.home, self.root / HOME_FOLDER, symlinks=True) + except OSError as error: + (self.root / HOME_COPY_NOTE).write_text( + f"The copy of the scenario's home is incomplete:\n{error}\n", + encoding="utf-8", + ) def child_environment( diff --git a/tests/suite/screens/homes.py b/tests/suite/screens/homes.py new file mode 100644 index 000000000..e64fe0144 --- /dev/null +++ b/tests/suite/screens/homes.py @@ -0,0 +1,92 @@ +import os +import shutil +import stat +import tempfile +from pathlib import Path +from typing import Final, Optional + +from tests.suite.screens.paths import HOMES_PREFIX, PROCESSES_DIRECTORY + +HIDDEN_PREFIX: Final[str] = "." +PROCESS_SEPARATOR: Final[str] = "-" + + +class HiddenHomesError(RuntimeError): + """Raised when the temporary folder scenario homes would be made in lies inside a hidden folder.""" + + +def make_worker_homes() -> Path: + """Makes the temporary folder one worker keeps its scenario homes in, named after the worker's process. + + The application's browsers leave hidden folders out, so the homes need a temporary folder outside + them. The folders that crashed workers left, whose process has gone, go first. + + Raises: + HiddenHomesError: If the temporary folder lies inside a hidden folder. + """ + parent = Path(tempfile.gettempdir()) + hidden = [part for part in parent.parts if part.startswith(HIDDEN_PREFIX)] + if hidden: + raise HiddenHomesError( + f"Screen scenario homes are made in {parent}, which lies inside the hidden folder '{hidden[0]}', " + "and the application's browsers leave hidden folders out. Point TMPDIR at a folder outside hidden " + "folders." + ) + + _let_stale_homes_go(parent) + return Path(tempfile.mkdtemp(prefix=f"{HOMES_PREFIX}{os.getpid()}{PROCESS_SEPARATOR}", dir=parent)) + + +def let_go(folder: Path) -> None: + """Removes ``folder``, opening first every folder inside it a scenario left locked.""" + reopen_folders(folder) + shutil.rmtree(folder, ignore_errors=True) + + +def reopen_folders(top: Path) -> None: + """Gives the owner back every folder under ``top``, so its files can be read, copied and removed. + + A scenario that locks a folder opens it again as it ends, and one that crashed or was stopped + leaves it locked. A folder whose mode cannot change stays as it is, and the copy and the removal + after it take what they can. + """ + _open_to_owner(top) + for folder, subfolders, _ in os.walk(top): + for subfolder in subfolders: + _open_to_owner(Path(folder) / subfolder) + + +def _open_to_owner(folder: Path) -> None: + if folder.is_symlink(): + return + + try: + folder.chmod(folder.stat().st_mode | stat.S_IRWXU) + except OSError: + return + + +def _let_stale_homes_go(parent: Path) -> None: + """Removes the homes folders under ``parent`` whose worker process has gone, and keeps every other one. + + A worker removes its own folder as it ends, so a folder left behind belongs to a worker that crashed, + or to a run still going. A folder whose name carries no process is left alone. + """ + for folder in parent.glob(f"{HOMES_PREFIX}*"): + process = _owner_process(folder) + if process is not None and not _is_running(process): + let_go(folder) + + +def _owner_process(folder: Path) -> Optional[int]: + """The process id a homes folder's name carries before its separator, or ``None`` for a name that carries none. + + A temporary folder's random tail holds no separator, so a name made without a process reads as none. + """ + process, separator, _ = folder.name.removeprefix(HOMES_PREFIX).partition(PROCESS_SEPARATOR) + return int(process) if separator and process.isdigit() else None + + +def _is_running(process: int) -> bool: + """Whether the process ``process`` names still runs, read from the process folder Linux keeps.""" + return (PROCESSES_DIRECTORY / str(process)).exists() diff --git a/tests/suite/screens/paths.py b/tests/suite/screens/paths.py index acd102aea..8e764683c 100644 --- a/tests/suite/screens/paths.py +++ b/tests/suite/screens/paths.py @@ -9,6 +9,8 @@ ARTIFACTS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "build" / "screens" HOME_FOLDER: Final[str] = "home" HOMES_PREFIX: Final[str] = "sampletones-screens-" +HOME_COPY_NOTE: Final[str] = "home-copy-incomplete.txt" +PROCESSES_DIRECTORY: Final[Path] = Path("/proc") 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/hooks.py b/tests/suite/screens/plugin/hooks.py index 5bdf7c402..608373cfb 100644 --- a/tests/suite/screens/plugin/hooks.py +++ b/tests/suite/screens/plugin/hooks.py @@ -1,6 +1,4 @@ import os -import shutil -import tempfile from pathlib import Path from typing import Final, Generator, Optional @@ -10,7 +8,8 @@ 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 HOMES_PREFIX, SCREENS_DIRECTORY +from tests.suite.screens.homes import let_go, make_worker_homes +from tests.suite.screens.paths import SCREENS_DIRECTORY from tests.suite.screens.plugin.constants import DISPLAY_KEY, HOMES_KEY from tests.suite.screens.plugin.fixtures import _worker_display @@ -71,13 +70,14 @@ def pytest_runtest_protocol( def _worker_homes(config: pytest.Config) -> Path: """The temporary folder this worker's scenarios keep their homes in, made with the first of them. - Each worker makes its own, so runs from several checkouts at once keep their homes apart. + Each worker makes its own, named after its process, so runs from several checkouts at once keep + their homes apart, and the first scenario of a run lets go of what crashed workers left. """ homes = config.stash.get(HOMES_KEY, None) if homes is not None: return homes - homes = Path(tempfile.mkdtemp(prefix=HOMES_PREFIX)) + homes = make_worker_homes() config.stash[HOMES_KEY] = homes return homes @@ -97,7 +97,7 @@ def pytest_unconfigure(config: pytest.Config) -> None: homes = config.stash.get(HOMES_KEY, None) if homes is not None: - shutil.rmtree(homes, ignore_errors=True) + let_go(homes) @pytest.hookimpl(wrapper=True) From 52b98137673a9f93da838bd6dd75f781dadcb071 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:19:34 +0200 Subject: [PATCH 66/84] Fixed: a library folder loading on the way back a library the reader never opened --- .../logic/instruction/library.py | 18 ++++----- .../logic/instruction/library_manager.py | 35 ++++++++++++++--- .../logic/instruction/test_library_logic.py | 38 +++++++++++++++++++ .../logic/instruction/test_library_manager.py | 35 +++++++++++++++++ 4 files changed, 110 insertions(+), 16 deletions(-) diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py index c909760de..05fd49521 100644 --- a/src/sampletones_application/logic/instruction/library.py +++ b/src/sampletones_application/logic/instruction/library.py @@ -72,7 +72,6 @@ def __init__( self._library_manager = library_manager self._is_operation_active = is_operation_active self._eta_estimator: Optional[ETAEstimator] = None - self._remembered_key: Optional[InstructionLibraryKey] = None self._lock_function: Optional[VoidCallback] = None self._unlock_function: Optional[VoidCallback] = None @@ -150,12 +149,10 @@ def rebuild_tree(self) -> None: def refresh_libraries(self, load_if_needed: bool = True) -> None: """Reads the catalog of the configuration's library directory and asks for its tree. - A directory the catalog moves to brings back the library it had taken up when the reader - left it, which :meth:`reload_remembered_library` loads again. + A directory the reader comes back to loads again the library it had loaded when the reader + left it, through :meth:`reload_remembered_library`. """ - if self._library_manager.set_library_directory(self._config_manager.get_library_directory()): - self._remembered_key = self._library_manager.current_library_key - + self._library_manager.set_library_directory(self._config_manager.get_library_directory()) self._library_manager.gather_available_libraries() self._sync_with_config_key(load_if_needed=load_if_needed) self.reload_remembered_library() @@ -177,7 +174,7 @@ def follow_config(self) -> None: self.update_status() def reload_remembered_library(self) -> None: - """Loads again the library a directory had taken up when the reader left it, once the reader is + """Loads again the library a directory had loaded when the reader left it, once the reader is back and the tree stands free. The load is the one a Load runs, so a failure, such as a file removed meanwhile, is reported @@ -185,12 +182,11 @@ def reload_remembered_library(self) -> None: the directory's choice. A generation or a rebuild holding the tree's lock keeps the load waiting, and the coordinator calls this again once the lock is let go. """ - key = self._remembered_key - if key is None or self._is_locked: + if self._is_locked: return - self._remembered_key = None - if self.current_library_key != key or self._library_manager.is_library_loaded(key): + key = self._library_manager.take_released_library() + if key is None or self.current_library_key != key or self._library_manager.is_library_loaded(key): return if self._load_library(key) is None: diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index 237532115..fe623a312 100644 --- a/src/sampletones_application/logic/instruction/library_manager.py +++ b/src/sampletones_application/logic/instruction/library_manager.py @@ -46,11 +46,23 @@ class _Catalog: """The libraries one directory holds in memory, and the one taken up as current there. Only the directory the catalog stands at holds libraries in memory. Every directory keeps its - choice of current library. + choice of current library. A directory left keeps, as ``released_key``, the current library it + had loaded, which the reader coming back gets loaded again. """ library: InstructionLibrary current_key: Optional[InstructionLibraryKey] + released_key: Optional[InstructionLibraryKey] = None + + def release(self) -> None: + """Lets go of every library loaded here, remembering the current one where it was loaded. + + A release still waiting to be taken stays until a loaded current library replaces it. + """ + if self.current_key is not None and self.current_key in self.library.data: + self.released_key = self.current_key + + self.library.data.clear() class InstructionsLibraryManager(CallbackMixin): @@ -85,19 +97,29 @@ def set_library_directory(self, directory: Path) -> bool: """Roots the catalog at ``directory``, and answers whether that moved it to another folder. The folder left lets go of the libraries it loaded and keeps the one it had taken up as - current, so a reader coming back finds that choice again, for the caller to load. Every - spelling of one folder, a link to it included, names the same catalog. + current. Where that library was loaded, the folder remembers it, and a reader coming back + gets it from :meth:`take_released_library` to load again. Every spelling of one folder, a + link to it included, names the same catalog. """ catalog = self._catalog_at(directory) with self._catalog_lock: if catalog is self._catalog: return False - self._catalog.library.data.clear() + self._catalog.release() self._catalog = catalog return True + def take_released_library(self) -> Optional[InstructionLibraryKey]: + """The library the folder the catalog stands at had loaded when the reader last left it, handed + over once.""" + with self._catalog_lock: + key = self._catalog.released_key + self._catalog.released_key = None + + return key + def _catalog_at(self, directory: Path) -> _Catalog: """The catalog of ``directory``, started empty the first time the directory is read.""" root = to_path(directory).resolve() @@ -244,7 +266,8 @@ def _complete_generation( makes it that catalog's current library. The library stays in memory only where the catalog still stands there. A catalog left holds - the file and the choice, and the reader coming back loads it from the file. + the file and the choice, and remembers the library as loaded, so the reader coming back + loads it from the file. """ key, library_data = result try: @@ -257,6 +280,8 @@ def _complete_generation( catalog.current_key = key if catalog is self._catalog: catalog.library.data[key] = library_data + else: + catalog.released_key = key self.call(self.on_generation_completed) diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py index 765cbfa3b..c18d51a42 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py @@ -396,6 +396,7 @@ class Catalog: views: List[LibraryPanelViewModel] = field(default_factory=list) outdated: List[InstructionLibraryKey] = field(default_factory=list) missing: List[Path] = field(default_factory=list) + errors: List[Exception] = field(default_factory=list) shown: List[Any] = field(default_factory=list) @property @@ -444,6 +445,7 @@ def catalog( logic.on_apply_library_config = config_manager.apply_library_config logic.on_library_outdated = catalog.outdated.append logic.on_load_file_not_found = lambda path, message: catalog.missing.append(path) + logic.on_load_error = lambda exception, message: catalog.errors.append(exception) logic.on_instruction_loaded = catalog.shown.append return catalog @@ -594,6 +596,42 @@ def test_a_folder_pointed_away_from_and_back_loads_its_library_again( "Regenerate", ) + def test_a_library_the_folder_only_took_up_stays_unloaded_on_the_way_back( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """A start takes up the library the configuration names without loading it, so the reader never + opened it and the way back leaves it as it stood.""" + ours = catalog.config_manager.get_library_directory() + key = _write_library(ours, catalog.config_manager.config.library, SAMPLETONES_LIBRARY_DATA_VERSION) + catalog.logic.refresh_libraries(load_if_needed=False) + taken_up = (catalog.manager.current_library_key, catalog.manager.is_library_loaded(key)) + + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + aim_library_directory(catalog.config_manager, ours) + + assert (taken_up, catalog.manager.is_library_loaded(key), catalog.views[-1].generate_button_label) == ( + (key, False), + False, + "Generate", + ) + + def test_a_library_never_opened_whose_file_left_while_away_goes_unreported( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + ours = catalog.config_manager.get_library_directory() + key = _write_library(ours, catalog.config_manager.config.library, SAMPLETONES_LIBRARY_DATA_VERSION) + catalog.logic.refresh_libraries(load_if_needed=False) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + (ours / key.filename).unlink() + + aim_library_directory(catalog.config_manager, ours) + + assert (catalog.missing, catalog.errors) == ([], []) + @requires_symlinks def test_a_link_to_the_same_folder_repaints_over_what_is_loaded( self, diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_manager.py b/tests/unit/sampletones_application/logic/instruction/test_library_manager.py index 2d7d4c7ce..ab58e49e2 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_manager.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_manager.py @@ -177,6 +177,41 @@ def test_pointing_away_lets_the_data_go_and_keeps_the_choice( library_manager.current_library_key, ) == ({}, False, config_manager.key) + def test_the_way_back_hands_over_the_library_loaded_when_the_reader_left_once( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + ours = config_manager.get_library_directory() + self._loaded_here(library_manager, config_manager.key) + library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) + library_manager.set_library_directory(ours) + + assert (library_manager.take_released_library(), library_manager.take_released_library()) == ( + config_manager.key, + None, + ) + + def test_a_library_taken_up_unloaded_leaves_nothing_to_load_on_the_way_back( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + tmp_path: Path, + ) -> None: + """The configuration's library is taken up at a start without loading, so the reader never opened + it and a way back has nothing to load again.""" + ours = config_manager.get_library_directory() + _create_library_file(library_manager, config_manager.key) + library_manager.sync_with_config_key(config_manager.key) + library_manager.set_library_directory(tmp_path / OTHER_LIBRARIES) + library_manager.set_library_directory(ours) + + assert (library_manager.current_library_key, library_manager.take_released_library()) == ( + config_manager.key, + None, + ) + def test_what_the_other_directory_took_up_stays_with_it( self, config_manager: ConfigManager, From ec51208b2aaae49ffe4d3ae3ba77ed73df1178e5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:20:42 +0200 Subject: [PATCH 67/84] Fixed: a library folder reached again showing a version error where Load asks to rebuild --- .../logic/instruction/library.py | 42 ++++++++++++------- .../logic/instruction/test_library_logic.py | 41 ++++++++++++++++++ 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py index 05fd49521..d865b6da3 100644 --- a/src/sampletones_application/logic/instruction/library.py +++ b/src/sampletones_application/logic/instruction/library.py @@ -177,10 +177,12 @@ def reload_remembered_library(self) -> None: """Loads again the library a directory had loaded when the reader left it, once the reader is back and the tree stands free. - The load is the one a Load runs, so a failure, such as a file removed meanwhile, is reported - the same way, and the directory lets that choice go. The library loads only while it is still - the directory's choice. A generation or a rebuild holding the tree's lock keeps the load - waiting, and the coordinator calls this again once the lock is let go. + The library opens the way a Load opens it, so a file removed meanwhile is reported missing, a + file another version wrote asks for a rebuild, and a file that fails to load is reported. A + library that stays unloaded stops being the directory's choice. The settings stay as the + reader left them. The library loads only while it is still the directory's choice. A + generation or a rebuild holding the tree's lock keeps the load waiting, and the coordinator + calls this again once the lock is let go. """ if self._is_locked: return @@ -189,7 +191,7 @@ def reload_remembered_library(self) -> None: if key is None or self.current_library_key != key or self._library_manager.is_library_loaded(key): return - if self._load_library(key) is None: + if self._load_readable_library(key) is None: self._library_manager.clear_current_library() self.update_status() @@ -338,23 +340,35 @@ def _open_library(self, library_key: InstructionLibraryKey) -> bool: Returns: bool: Whether the library is loaded. """ + library_data = self._load_readable_library(library_key) + if library_data is not None: + self.call(self.on_apply_library_config, library_key, library_data.config) + + self.update_status() + return library_data is not None + + def _load_readable_library(self, library_key: InstructionLibraryKey) -> Optional[InstructionLibraryData]: + """Loads the library ``library_key`` names where this build reads it. + + A library another version built is put to the reader through ``on_library_outdated``, and a + missing one is reported. + + Returns: + Optional[InstructionLibraryData]: The library loaded, or ``None`` where the tree is locked + or the library stays unloaded. + """ if self._is_locked: - return False + return None match self._library_manager.library_state(library_key): case LibraryState.OUTDATED: self.call(self.on_library_outdated, library_key) - return False + return None case LibraryState.MISSING: self._report_missing(library_key) - return False - - library_data = self._load_library(library_key) - if library_data is not None: - self.call(self.on_apply_library_config, library_key, library_data.config) + return None - self.update_status() - return library_data is not None + return self._load_library(library_key) def _report_missing(self, library_key: InstructionLibraryKey) -> None: logger.warning(f"Library file not found for key {library_key}") diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py index c18d51a42..50df44288 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py @@ -672,6 +672,47 @@ def test_a_library_whose_file_left_while_away_is_reported_on_the_way_back( None, ) + def test_a_library_another_build_rewrote_while_away_asks_to_be_rebuilt_on_the_way_back( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """The way back opens the library as a Load does, so a file another version wrote meanwhile asks + for a rebuild, and the folder's choice goes with it.""" + ours = catalog.config_manager.get_library_directory() + key = _opened_library(catalog) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + _write_library(ours, catalog.config_manager.config.library, EARLIER_VERSION) + + aim_library_directory(catalog.config_manager, ours) + + assert ( + catalog.outdated, + catalog.errors, + catalog.manager.is_library_loaded(key), + catalog.manager.current_library_key, + ) == ([key], [], False, None) + + def test_the_way_back_keeps_the_settings_the_reader_left_with( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """The way back brings the folder's library back as it stood, and the settings stay as the reader + set them while away.""" + ours = catalog.config_manager.get_library_directory() + key = _opened_library(catalog) + aim_library_directory(catalog.config_manager, tmp_path / OTHER_LIBRARIES) + settings = _other_settings(catalog) + catalog.config_manager.apply_library_config( + InstructionLibraryKey.create(settings, Window.from_config(settings)), + settings, + ) + + aim_library_directory(catalog.config_manager, ours) + + assert (catalog.manager.is_library_loaded(key), catalog.config_manager.config.library) == (True, settings) + def test_a_library_generated_while_away_is_written_held_nowhere_and_loaded_on_the_way_back( self, catalog: Catalog, From 41f25a30e46e2b119c81a7910281431bdab427f2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:23:17 +0200 Subject: [PATCH 68/84] Fixed: library notices and confirmations naming a linked folder's target in place of the folder the reader chose --- .../logic/instruction/library.py | 9 ++--- .../logic/instruction/library_manager.py | 22 ++++++++-- src/sampletones_core/library/library.py | 4 +- .../logic/instruction/test_library_logic.py | 40 +++++++++++++++++++ .../logic/instruction/test_library_manager.py | 31 ++++++++++++-- .../sampletones_core/library/test_library.py | 21 ++++++++++ 6 files changed, 114 insertions(+), 13 deletions(-) diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py index d865b6da3..604b62cea 100644 --- a/src/sampletones_application/logic/instruction/library.py +++ b/src/sampletones_application/logic/instruction/library.py @@ -46,7 +46,6 @@ from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin from sampletones_shared.utils.system.filesystem import remove_path -from sampletones_shared.utils.system.paths import is_same_path OnLoadInstructionCallback = Callable[[InstructionUnion], None] OnApplyLibraryConfigCallback = Callable[[InstructionLibraryKey, Optional[InstructionsLibraryConfig]], None] @@ -163,11 +162,11 @@ def follow_config(self) -> None: library directory and repainting the status otherwise. Another spelling of the folder the catalog stands at, a link to it included, names the same - folder, so the catalog stays as it is. A generation writes into the catalog it was started - in, so the catalog follows a change at once, whatever is running; the tree it lists is drawn - once the generation lets its lock go. + folder, so the catalog keeps what it holds and takes that spelling for the paths it shows. A + generation writes into the catalog it was started in, so the catalog follows a change at + once, whatever is running; the tree it lists is drawn once the generation lets its lock go. """ - if not is_same_path(self._library_manager.library_directory, self._config_manager.get_library_directory()): + if self._library_manager.set_library_directory(self._config_manager.get_library_directory()): self.refresh_libraries(load_if_needed=False) return diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index fe623a312..00e849b47 100644 --- a/src/sampletones_application/logic/instruction/library_manager.py +++ b/src/sampletones_application/logic/instruction/library_manager.py @@ -54,6 +54,17 @@ class _Catalog: current_key: Optional[InstructionLibraryKey] released_key: Optional[InstructionLibraryKey] = None + def name_by(self, directory: Path) -> None: + """Names the folder by ``directory``, the spelling the reader configured last, keeping what the + folder holds. + + Every spelling names one folder, so a generation writing through the spelling it read first + lands in the same place. + """ + spelling = str(directory) + if self.library.directory != spelling: + self.library = self.library.model_copy(update={"directory": spelling}) + def release(self) -> None: """Lets go of every library loaded here, remembering the current one where it was loaded. @@ -99,10 +110,11 @@ def set_library_directory(self, directory: Path) -> bool: The folder left lets go of the libraries it loaded and keeps the one it had taken up as current. Where that library was loaded, the folder remembers it, and a reader coming back gets it from :meth:`take_released_library` to load again. Every spelling of one folder, a - link to it included, names the same catalog. + link to it included, names the same catalog, and the catalog goes by the spelling given last. """ catalog = self._catalog_at(directory) with self._catalog_lock: + catalog.name_by(to_path(directory)) if catalog is self._catalog: return False @@ -121,12 +133,14 @@ def take_released_library(self) -> Optional[InstructionLibraryKey]: return key def _catalog_at(self, directory: Path) -> _Catalog: - """The catalog of ``directory``, started empty the first time the directory is read.""" - root = to_path(directory).resolve() + """The catalog of the folder ``directory`` names, started empty under that spelling the first time + the folder is read.""" + spelling = to_path(directory) + root = spelling.resolve() catalog = self._catalogs.get(root) if catalog is None: catalog = _Catalog( - library=InstructionLibrary(directory=str(root)), + library=InstructionLibrary(directory=str(spelling)), current_key=None, ) self._catalogs[root] = catalog diff --git a/src/sampletones_core/library/library.py b/src/sampletones_core/library/library.py index 6d1309ea1..4450f7cf7 100644 --- a/src/sampletones_core/library/library.py +++ b/src/sampletones_core/library/library.py @@ -130,12 +130,14 @@ def save_data(self, key: InstructionLibraryKey, library_data: InstructionLibrary def write_data(self, key: InstructionLibraryKey, library_data: InstructionLibraryData) -> None: """Writes a library to its file on disk, creating the library directory where it is missing. + A directory named through a link creates the folder the link points to. + Args: key: The key identifying the library. library_data: The library to persist. """ path = self.get_path(key) - path.parent.mkdir(parents=True, exist_ok=True) + path.parent.resolve().mkdir(parents=True, exist_ok=True) library_data.save(path) def load_data(self, key: InstructionLibraryKey) -> None: diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py index 50df44288..29261b8d3 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py @@ -649,6 +649,46 @@ def test_a_link_to_the_same_folder_repaints_over_what_is_loaded( assert (len(catalog.rebuilds_under_lock), catalog.manager.is_library_loaded(key)) == (rebuilds, True) assert catalog.views[-1].generate_button_label == "Regenerate" + @requires_symlinks + def test_a_notice_names_the_folder_by_the_link_the_reader_chose( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + key = _opened_library(catalog) + link = tmp_path / LINKED_LIBRARIES + link.symlink_to(catalog.config_manager.get_library_directory(), target_is_directory=True) + aim_library_directory(catalog.config_manager, link) + (link / key.filename).unlink() + + catalog.logic.load_library_and_set_current(key) + + assert catalog.missing == [link / key.filename] + + @requires_symlinks + def test_a_library_generated_across_a_respelling_lands_loaded_under_the_new_spelling( + self, + catalog: Catalog, + tmp_path: Path, + ) -> None: + """A link names the folder the generation started in, so the generation's library stays in memory, + and its path goes through the link.""" + ours = catalog.config_manager.get_library_directory() + ours.mkdir(parents=True) + link = tmp_path / LINKED_LIBRARIES + link.symlink_to(ours, target_is_directory=True) + catalog.start_generation() + + aim_library_directory(catalog.config_manager, link) + catalog.write_library() + catalog.queue.drain() + + key = catalog.config_manager.key + assert (catalog.manager.is_library_loaded(key), catalog.manager.get_path(key)) == ( + True, + link / key.filename, + ) + def test_a_library_whose_file_left_while_away_is_reported_on_the_way_back( self, catalog: Catalog, diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_manager.py b/tests/unit/sampletones_application/logic/instruction/test_library_manager.py index ab58e49e2..eb3b51f14 100644 --- a/tests/unit/sampletones_application/logic/instruction/test_library_manager.py +++ b/tests/unit/sampletones_application/logic/instruction/test_library_manager.py @@ -17,7 +17,13 @@ from sampletones_core.structures.tree import LibraryNode from tests.suite.compatibility import LIBRARY_VERSION, archived from tests.suite.files import requires_symlinks -from tests.suite.library import LINKED_LIBRARIES, OTHER_LIBRARIES, WrittenLibrary, write_empty_library +from tests.suite.library import ( + LINKED_LIBRARIES, + OTHER_LIBRARIES, + WrittenLibrary, + aim_library_directory, + write_empty_library, +) @pytest.fixture @@ -252,7 +258,7 @@ def test_a_library_whose_file_left_while_away_is_let_go( class TestAnotherSpellingOfTheFolder: """Two spellings of one folder name one catalog, so pointing the catalog at another spelling keeps - what the folder loaded and moves nowhere.""" + what the folder loaded and moves nowhere. The folder goes by the spelling given last.""" @staticmethod def _respelled_keeps_what_it_loaded( @@ -265,7 +271,12 @@ def _respelled_keeps_what_it_loaded( moved = library_manager.set_library_directory(spelling) - assert (moved, library_manager.is_library_loaded(config_manager.key)) == (False, True) + assert ( + moved, + library_manager.is_library_loaded(config_manager.key), + library_manager.library_directory, + library_manager.get_path(config_manager.key), + ) == (False, True, spelling, spelling / config_manager.key.filename) def test_a_detour_through_the_parent( self, @@ -289,6 +300,20 @@ def test_a_link_to_the_folder( self._respelled_keeps_what_it_loaded(config_manager, library_manager, link) + @requires_symlinks + def test_a_folder_configured_through_a_link_goes_by_the_link_from_the_start( + self, + config_manager: ConfigManager, + tmp_path: Path, + ) -> None: + link = tmp_path / LINKED_LIBRARIES + link.symlink_to(config_manager.get_library_directory(), target_is_directory=True) + aim_library_directory(config_manager, link) + + library_manager = InstructionsLibraryManager(config_manager, language_manager=MagicMock()) + + assert library_manager.get_path(config_manager.key) == link / config_manager.key.filename + class TestCompleteGeneration: """A failed library save is an operational failure the user must see. diff --git a/tests/unit/sampletones_core/library/test_library.py b/tests/unit/sampletones_core/library/test_library.py index 365e84214..e32592547 100644 --- a/tests/unit/sampletones_core/library/test_library.py +++ b/tests/unit/sampletones_core/library/test_library.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Final import pytest @@ -8,6 +9,7 @@ from sampletones_core.library.key import InstructionLibraryKey from sampletones_core.library.library import InstructionLibrary from sampletones_core.library.state import LibraryState +from tests.suite.files import requires_symlinks @pytest.fixture(scope="module") @@ -39,6 +41,10 @@ def empty_library_data(config: Config) -> InstructionLibraryData: return InstructionLibraryData.create(config, {}) +LINK_TARGET: Final[str] = "libraries" +LINK: Final[str] = "linked_libraries" + + class TestInstructionLibraryFromConfig: def test_from_config_creates_instance(self, config: Config) -> None: library = InstructionLibrary.from_config(config) @@ -133,3 +139,18 @@ def test_get_path_includes_filename( ) -> None: path = library.get_path(library_key) assert path.name == library_key.filename + + @requires_symlinks + def test_a_library_written_through_a_link_to_a_folder_yet_to_exist_makes_that_folder( + self, + library_key: InstructionLibraryKey, + empty_library_data: InstructionLibraryData, + tmp_path: Path, + ) -> None: + target = tmp_path / LINK_TARGET + link = tmp_path / LINK + link.symlink_to(target, target_is_directory=True) + + InstructionLibrary(directory=str(link)).write_data(library_key, empty_library_data) + + assert (target / library_key.filename).is_file() From 402014d894fd9ee3a930f18bc6f1a61130ae7ad5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:25:05 +0200 Subject: [PATCH 69/84] Fixed: a question that fails as it reaches the free screen leaving its gesture in flight for good --- docs/development/application/dialogs.md | 5 +- .../utils/callbacks/gates.py | 52 +++++++++-- tests/suite/questions.py | 17 ++++ .../coordinators/tabs/test_instructions.py | 5 +- .../coordinators/tabs/test_main.py | 5 +- .../coordinators/test_project.py | 34 +++++-- .../coordinators/test_reconstruction.py | 18 ++-- .../utils/callbacks/test_gates.py | 89 +++++++++++++++++++ 8 files changed, 194 insertions(+), 31 deletions(-) diff --git a/docs/development/application/dialogs.md b/docs/development/application/dialogs.md index f743227a4..f0e557e66 100644 --- a/docs/development/application/dialogs.md +++ b/docs/development/application/dialogs.md @@ -96,8 +96,9 @@ stands aside, so nothing waiting in line opens between it and the prompt it rais A gesture that asks before it replaces a document, closes one or leaves the application holds one conversation at a time. The conversation is the chain of questions the gesture passes, and every way out of a question reaches whoever asked it. An answer that goes on lets the request through. Cancel, Escape, the -title bar's close and a save that failed turn it away. So does an error raised anywhere along the chain, -before a question or after its answer. A request therefore always ends in one of the two. +title bar's close and a save that failed turn it away. So does an error raised anywhere along the chain: +before a question, as the question reaches the screen, or after its answer. A request therefore always ends +in one of the two. While a conversation stands, the same gesture asked for again asks nothing, so two closes before the first is answered ask once. Once the conversation has ended, the gesture asks again. The span covers the wait for the diff --git a/src/sampletones_application/utils/callbacks/gates.py b/src/sampletones_application/utils/callbacks/gates.py index 3f2caf777..6049bce2a 100644 --- a/src/sampletones_application/utils/callbacks/gates.py +++ b/src/sampletones_application/utils/callbacks/gates.py @@ -35,7 +35,9 @@ def asking( While ``unsettled`` reads false, the request goes on at once, whatever holds the screen. Otherwise the gate waits for the screen and reads ``unsettled`` again there, so the question speaks of the state it shows over. A thing another conversation settled meanwhile, such as a project its answer - closed, lets the request through with no question. + closed, lets the request through with no question. The step on the free screen runs from the + line, so one that raises before it hands the request on turns the request away, and the error + goes on up. Args: unsettled: Whether there is something to ask about. @@ -48,17 +50,49 @@ def gate(proceed: VoidCallback, decline: VoidCallback) -> None: proceed() return - def ask_on_the_screen() -> None: + def ask_on_the_screen(go_on: VoidCallback, turn_away: VoidCallback) -> None: if unsettled(): - question(proceed, decline) + question(go_on, turn_away) else: - proceed() + go_on() - screen(ask_on_the_screen) + screen(_declining_on_failure(ask_on_the_screen, proceed, decline)) return gate +def _declining_on_failure( + step: Gate, + proceed: VoidCallback, + decline: VoidCallback, +) -> VoidCallback: + """``step`` as a callback handed ``proceed`` and ``decline``, which calls ``decline`` where it raises before either. + + A step that called one of the two has handed the request on, and a failure after that belongs to + the continuation it called. + """ + + def run() -> None: + settled = False + + def settling(continuation: VoidCallback) -> VoidCallback: + def settle() -> None: + nonlocal settled + settled = True + continuation() + + return settle + + try: + step(settling(proceed), settling(decline)) + settled = True + finally: + if not settled: + decline() + + return run + + def pass_gates( gates: Sequence[Gate], arrive: VoidCallback, @@ -128,10 +162,10 @@ class SingleFlight(ABC, Generic[GestureParameters]): asked for until the gates let it through or turn it away, and a gesture asked for after that asks again. A gesture asked for while it is in flight asks nothing, and the kind of flight decides which request its end lets through. A conversation that turns the gesture away drops the request it - holds. A gate, or a continuation a gate runs after an answer or a wait, that raises ends the - flight the same way, so one failure leaves the gesture to be asked for again. Each take-off has a - ticket, and a continuation acts on the flight of its own ticket alone, so an answer reaching a - conversation that has ended leaves the newer one as it stands. + holds. A gate that raises, as it is reached or in a step it runs after an answer, a wait or a + turn in the line, ends the flight the same way, so one failure leaves the gesture to be asked for + again. Each take-off has a ticket, and a continuation acts on the flight of its own ticket alone, + so an answer reaching a conversation that has ended leaves the newer one as it stands. """ def __init__(self, arrive: Callable[GestureParameters, GestureResult]) -> None: diff --git a/tests/suite/questions.py b/tests/suite/questions.py index c3973c413..c906d5965 100644 --- a/tests/suite/questions.py +++ b/tests/suite/questions.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from typing import Final, List, Optional +from unittest.mock import MagicMock import pytest @@ -95,3 +96,19 @@ def _take_question(self) -> StandingQuestion: question, self._standing = self._standing, None ModalQueue.leave(question.tag) return question + + +def assert_the_answers_reach( + *, + confirm: VoidCallback, + cancel: VoidCallback, + proceed: MagicMock, + decline: MagicMock, +) -> None: + """Holds a question's answers to the gate that asked it: the one going on reaches ``proceed``, Cancel ``decline``.""" + confirm() + proceed.assert_called_once_with() + decline.assert_not_called() + + cancel() + decline.assert_called_once_with() diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py index 560cb09a5..c58bfa234 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py @@ -13,7 +13,7 @@ from sampletones_shared.exceptions import LibraryDisplayError from tests.suite.frames import held_frames from tests.suite.language import FakeLanguageManager -from tests.suite.questions import StandingWindow, standing_window +from tests.suite.questions import StandingWindow, assert_the_answers_reach, standing_window __all__ = ["held_frames", "standing_window"] @@ -382,6 +382,5 @@ def test_a_library_being_built_asks_first(self) -> None: args, kwargs = coordinator._dialogs.show_confirmation.call_args assert args[0] == TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION assert args[1] == EXIT_LIBRARY_MESSAGE_KEY - assert args[3] is proceed assert kwargs["ok_label"] == EXIT_LABEL_KEY - assert kwargs["on_cancel"] is decline + assert_the_answers_reach(confirm=args[3], cancel=kwargs["on_cancel"], proceed=proceed, decline=decline) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 531dce178..c28a7fc45 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -26,7 +26,7 @@ from tests.suite.files import LOCKED_FOLDER, held_at, requires_folder_permissions from tests.suite.frames import held_frames from tests.suite.language import FakeLanguageManager -from tests.suite.questions import StandingWindow, standing_window +from tests.suite.questions import StandingWindow, assert_the_answers_reach, standing_window __all__ = ["held_frames", "standing_window"] @@ -697,6 +697,5 @@ def test_a_running_conversion_asks_first(self) -> None: args, kwargs = coordinator._dialogs.show_confirmation.call_args assert args[0] == TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION assert args[1] == EXIT_CONVERSION_MESSAGE_KEY - assert args[3] is proceed assert kwargs["ok_label"] == EXIT_LABEL_KEY - assert kwargs["on_cancel"] is decline + assert_the_answers_reach(confirm=args[3], cancel=kwargs["on_cancel"], proceed=proceed, decline=decline) diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index 7c452ced5..f1912afee 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -34,7 +34,7 @@ from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.frames import held_frames -from tests.suite.questions import StandingWindow, standing_window +from tests.suite.questions import StandingWindow, assert_the_answers_reach, standing_window from tests.suite.silent_rows import MISSING_VOICE_ID, SILENT_CHANNEL __all__ = ["held_frames", "standing_window"] @@ -587,8 +587,12 @@ def test_an_unsaved_project_asks_to_save_first(self, project_coordinator: Projec decline.assert_not_called() prompt = project_coordinator._dialogs.show_save_confirmation.call_args.kwargs assert prompt["on_save"] == project_coordinator._write_project - assert prompt["on_confirm"] is proceed - assert prompt["on_cancel"] is decline + assert_the_answers_reach( + confirm=prompt["on_confirm"], + cancel=prompt["on_cancel"], + proceed=proceed, + decline=decline, + ) class TestReplacingOrClosingTheProject: @@ -643,8 +647,12 @@ def test_an_unsaved_project_asks_and_cancel_turns_the_request_away( proceed.assert_not_called() prompt = project_coordinator._dialogs.show_save_confirmation.call_args.kwargs - assert prompt["on_confirm"] is proceed - assert prompt["on_cancel"] is decline + assert_the_answers_reach( + confirm=prompt["on_confirm"], + cancel=prompt["on_cancel"], + proceed=proceed, + decline=decline, + ) @pytest.mark.parametrize("guard", ["guard_new", "guard_open"]) def test_a_saved_project_is_asked_about_and_cancel_turns_the_request_away( @@ -664,8 +672,12 @@ def test_a_saved_project_is_asked_about_and_cancel_turns_the_request_away( proceed.assert_not_called() prompt = project_coordinator._dialogs.show_confirmation.call_args.kwargs - assert prompt["on_confirm"] is proceed - assert prompt["on_cancel"] is decline + assert_the_answers_reach( + confirm=prompt["on_confirm"], + cancel=prompt["on_cancel"], + proceed=proceed, + decline=decline, + ) def test_a_saved_project_closes_at_once( self, @@ -759,8 +771,12 @@ def test_the_question_asks_once_the_window_leaves( standing_window.leave() prompt = unsaved._dialogs.show_save_confirmation.call_args.kwargs - assert prompt["on_confirm"] is proceed - assert prompt["on_cancel"] is decline + assert_the_answers_reach( + confirm=prompt["on_confirm"], + cancel=prompt["on_cancel"], + proceed=proceed, + decline=decline, + ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_a_project_settled_meanwhile_goes_on_with_no_question( diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index ed8fb5018..7af0aefc9 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -51,7 +51,7 @@ from tests.suite.case import BaseRegularTestCase from tests.suite.frames import held_frames from tests.suite.language import FakeLanguageManager -from tests.suite.questions import StandingWindow, standing_window +from tests.suite.questions import StandingWindow, assert_the_answers_reach, standing_window from tests.suite.stems import ( SHARED_CHANNEL, SHARED_OWNERS, @@ -1526,8 +1526,12 @@ def test_the_answer_lets_the_exit_go_on_and_cancel_turns_it_away(self) -> None: prompt = coordinator._dialogs.show_save_confirmation.call_args.kwargs assert prompt["on_save"] == coordinator.save - assert prompt["on_confirm"] is proceed - assert prompt["on_cancel"] is decline + assert_the_answers_reach( + confirm=prompt["on_confirm"], + cancel=prompt["on_cancel"], + proceed=proceed, + decline=decline, + ) @pytest.mark.parametrize("guard", ["guard_load", "guard_close"]) def test_cancel_on_a_load_or_a_close_turns_the_request_away(self, guard: str) -> None: @@ -1538,8 +1542,12 @@ def test_cancel_on_a_load_or_a_close_turns_the_request_away(self, guard: str) -> {"guard_load": coordinator.guard_load, "guard_close": coordinator.guard_close}[guard](proceed, decline) prompt = coordinator._dialogs.show_save_confirmation.call_args.kwargs - assert prompt["on_confirm"] is proceed - assert prompt["on_cancel"] is decline + assert_the_answers_reach( + confirm=prompt["on_confirm"], + cancel=prompt["on_cancel"], + proceed=proceed, + decline=decline, + ) @pytest.fixture diff --git a/tests/unit/sampletones_application/utils/callbacks/test_gates.py b/tests/unit/sampletones_application/utils/callbacks/test_gates.py index 173e65453..829adf691 100644 --- a/tests/unit/sampletones_application/utils/callbacks/test_gates.py +++ b/tests/unit/sampletones_application/utils/callbacks/test_gates.py @@ -241,6 +241,75 @@ def test_the_answer_reaches_whoever_asked(self, question: Guard, screen: HeldWai assert reached == ["question", DECLINED] + def test_a_question_that_fails_on_the_free_screen_turns_the_request_away( + self, + screen: HeldWait, + reached: List[str], + ) -> None: + """The failure goes on up from the turn, and whoever waits on the request hears it ended.""" + + def broken(_proceed: VoidCallback, _decline: VoidCallback) -> None: + raise RuntimeError("the question could not be asked") + + pass_gates( + (asking(lambda: True, broken, screen),), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + with pytest.raises(RuntimeError): + screen.release() + + assert reached == [DECLINED] + + def test_a_reading_that_fails_on_the_free_screen_turns_the_request_away( + self, + question: Guard, + screen: HeldWait, + reached: List[str], + ) -> None: + readings = [True] + + def unsettled() -> bool: + if not readings: + raise RuntimeError("the state could not be read") + + return readings.pop() + + pass_gates( + (asking(unsettled, question, screen),), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + with pytest.raises(RuntimeError): + screen.release() + + assert reached == [DECLINED] + assert not question.is_asking + + def test_a_failure_after_the_answer_reaches_whoever_asked_once( + self, + screen: HeldWait, + reached: List[str], + ) -> None: + """A question that went on before it failed has handed the request over, so nothing declines it again.""" + + def going_on_then_failing(proceed: VoidCallback, _decline: VoidCallback) -> None: + proceed() + raise RuntimeError("the question failed after it went on") + + pass_gates( + (asking(lambda: True, going_on_then_failing, screen),), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + with pytest.raises(RuntimeError): + screen.release() + + assert reached == [ARRIVED] + class RaisingOnce: """A gate that fails the first time it is reached and lets every later request through.""" @@ -429,6 +498,26 @@ def test_a_raise_once_the_question_is_answered_ends_the_flight( assert reached == ["question", "broken", "question", "broken", ARRIVED] assert not flight.in_flight + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_raise_once_the_screen_is_free_ends_the_flight(self, test_case: TestCase, reached: List[str]) -> None: + """A question waiting for the screen fails as it is asked there, and the gesture asks again when asked for.""" + screen = HeldWait() + flight = test_case.build( + (asking(lambda: True, RaisingOnce(reached), screen),), + lambda: reached.append(ARRIVED), + ) + flight() + + with pytest.raises(RuntimeError): + screen.release() + + assert not flight.in_flight + flight() + assert flight.in_flight + screen.release() + assert reached == ["broken", "broken", ARRIVED] + assert not flight.in_flight + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_a_stale_answer_going_on_leaves_the_newer_flight_standing( self, From 8047fc1721ad6caf82e35ded441de93f95b4a0fb Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:25:15 +0200 Subject: [PATCH 70/84] Fixed: a modal asked for in the frame after a failing turn jumping the line --- .../utils/gui/modal_queue.py | 35 ++++++++++--- .../utils/gui/test_modal_queue.py | 51 +++++++++++++++++-- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/src/sampletones_application/utils/gui/modal_queue.py b/src/sampletones_application/utils/gui/modal_queue.py index 1629041db..01b1f3105 100644 --- a/src/sampletones_application/utils/gui/modal_queue.py +++ b/src/sampletones_application/utils/gui/modal_queue.py @@ -88,6 +88,7 @@ class ModalQueue(metaclass=NonInstantiableMeta): _waiting: ClassVar[Deque[WaitingEntry]] = deque() _clearing: ClassVar[bool] = False _handing_off: ClassVar[bool] = False + _turning: ClassVar[bool] = False _turn_due: ClassVar[bool] = False @classmethod @@ -189,6 +190,7 @@ def clear(cls) -> None: cls._waiting = deque() cls._clearing = False cls._handing_off = False + cls._turning = False cls._turn_due = False @classmethod @@ -200,16 +202,27 @@ def _screen_taken(cls) -> bool: def _admit(cls, entry: WaitingEntry) -> None: """Takes ``entry`` now where the screen is free for it, and puts it in line otherwise. - An entry a hand-off asks for joins the conversation that handed it the screen, ahead of every - entry waiting in line. Any other one waits for the conversation holding the screen to end. + An entry a hand-off or a turn asks for joins the conversation at the head of the line, ahead of + every entry waiting. Any other one takes a free screen only while nothing waits in line, so an + entry asked for in the frame after a failing turn waits behind the ones the failure left. """ - if cls._shown is None and not cls._clearing and (cls._handing_off or not cls._screen_taken()): + if cls._shown is None and not cls._clearing and cls._head_is_free(): cls._take(entry) - elif cls._handing_off: + elif cls._joins_the_head(): cls._waiting.appendleft(entry) else: cls._waiting.append(entry) + @classmethod + def _joins_the_head(cls) -> bool: + """Whether what is asked for now comes from the head of the line: a hand-off or a turn running.""" + return cls._handing_off or cls._turning + + @classmethod + def _head_is_free(cls) -> bool: + """Whether an entry asked for now stands at the head of a line free for it.""" + return cls._joins_the_head() or (not cls._screen_taken() and not cls._waiting) + @classmethod def _take(cls, entry: WaitingEntry) -> None: """Gives ``entry`` the screen: a window is built on it, and a turn runs.""" @@ -218,7 +231,16 @@ def _take(cls, entry: WaitingEntry) -> None: cls._shown = tag build() case WaitingTurn(continuation=continuation): - continuation() + cls._run_turn(continuation) + + @classmethod + def _run_turn(cls, continuation: VoidCallback) -> None: + """Runs a turn at the head of the line, so what it asks for joins it ahead of the entries waiting.""" + turning, cls._turning = cls._turning, True + try: + continuation() + finally: + cls._turning = turning @classmethod def _forget_waiting(cls, tag: str) -> None: @@ -256,7 +278,8 @@ def _turn(cls) -> None: def _move_the_line(cls) -> None: """Gives the screen to the entries at the head of the line while it stays free. - A turn that raises leaves the entries behind it for a coming frame, so the line goes on there. + A turn that raises leaves the entries behind it for a coming frame, so the line goes on there, + and an entry asked for meanwhile waits behind them. """ moved = False try: 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 e691ac0aa..a3286ef7b 100644 --- a/tests/unit/sampletones_application/utils/gui/test_modal_queue.py +++ b/tests/unit/sampletones_application/utils/gui/test_modal_queue.py @@ -327,11 +327,8 @@ def test_a_turn_that_opens_a_modal_holds_the_line(self, screen: Screen, held_fra assert screen.built == [FIRST, PROMPT, SECOND] def test_a_turn_that_raises_leaves_the_line_moving(self, screen: Screen, held_frames: Frames) -> None: - def broken() -> None: - raise RuntimeError("the gesture failed") - screen.open(FIRST) - ModalQueue.when_free(broken) + ModalQueue.when_free(self.broken) screen.open(SECOND) ModalQueue.leave(FIRST) @@ -341,6 +338,52 @@ def broken() -> None: assert screen.built == [FIRST, SECOND] + def test_a_modal_asked_for_after_a_raising_turn_waits_behind_the_line( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + """The line goes on a frame after the failure, in the order it was asked.""" + screen.open(FIRST) + ModalQueue.when_free(self.broken) + screen.open(SECOND) + ModalQueue.leave(FIRST) + with pytest.raises(RuntimeError): + held_frames.render() + + screen.open(THIRD) + assert screen.built == [FIRST] + held_frames.render() + ModalQueue.leave(SECOND) + held_frames.render() + + assert screen.built == [FIRST, SECOND, THIRD] + + def test_a_turn_asked_for_after_a_raising_turn_waits_behind_the_line( + self, + screen: Screen, + held_frames: Frames, + ) -> None: + screen.open(FIRST) + ModalQueue.when_free(self.broken) + screen.open(SECOND) + ModalQueue.leave(FIRST) + with pytest.raises(RuntimeError): + held_frames.render() + + screen.take_a_turn() + assert screen.built == [FIRST] + held_frames.render() + assert screen.built == [FIRST, SECOND] + ModalQueue.leave(SECOND) + held_frames.render() + + assert screen.built == [FIRST, SECOND, TURN] + + @staticmethod + def broken() -> None: + raise RuntimeError("the gesture failed") + class TestAHandOffThatRaises: """A hand-off that fails leaves the conversation and the line to go on in a coming frame.""" From 3c68a7c2a08457a43fa4ce3afdd1f55cb6f0d6d3 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:26:03 +0200 Subject: [PATCH 71/84] Fixed: number fields keeping Alt chords they can't type, which blocked Ctrl+Alt shortcuts while editing a number --- docs/development/application/keyboard.md | 2 +- .../utils/gui/keyboard/focus/consumption.py | 11 +-- .../utils/gui/keyboard/focus/items.py | 6 +- .../utils/gui/keyboard/focus/kind.py | 11 ++- .../gui/keyboard/focus/test_consumption.py | 88 ++++++++++++++++++- .../utils/gui/keyboard/focus/test_items.py | 18 ++-- .../utils/gui/keyboard/focus/test_search.py | 2 +- 7 files changed, 119 insertions(+), 19 deletions(-) diff --git a/docs/development/application/keyboard.md b/docs/development/application/keyboard.md index 3e9365341..de4bd8255 100644 --- a/docs/development/application/keyboard.md +++ b/docs/development/application/keyboard.md @@ -40,7 +40,7 @@ The query resolves the focused item to the field behind it. A `dpg.group` report **Focus is claimed per key.** A focused input keeps the keys it genuinely consumes and yields the rest. A text or number field consumes `Space` and `Shift+Space`, because space is a character it types, and `Escape`, which cancels the field. Those keys serve the field while it holds focus. -A command, a combination held with Ctrl or Super, fires from anywhere, which is why playing from the shown frame works while typing. A text field keeps only the commands that edit its text: select all, copy, cut, paste, undo and redo, spelled with Ctrl and with Super. Alt is a typing key as well as a modifier: AltGr and Option type characters through it, and Windows reports AltGr as Ctrl+Alt. A text field therefore keeps an Alt combination on a key that types a character, while one on a function, caret or editing key fires, so `Alt+F4` and `Alt+Up` reach their actions from a field. An action meant to fire while the reader types takes Ctrl or Super. Playing from the cursor row belongs to the grid: the sequencer grid claims it while the grid itself holds the keyboard. +A command, a combination held with Ctrl or Super, fires from anywhere, which is why playing from the shown frame works while typing. A text or number field keeps only the commands that edit what it holds: select all, copy, cut, paste, undo and redo, spelled with Ctrl and with Super. Alt is a typing key as well as a modifier: AltGr and Option type characters through it, and Windows reports AltGr as Ctrl+Alt. A text field therefore keeps an Alt combination on a key that types a character, while one on a function, caret or editing key fires, so `Alt+F4` reaches its action from a field. A number field types no such character, so every Alt combination fires from it. An action meant to fire while the reader types takes Ctrl or Super. Playing from the cursor row belongs to the grid: the sequencer grid claims it while the grid itself holds the keyboard. **Interactive widgets release the keyboard.** A selectable cell or a transport button hands focus back after its click, so the next playback key reaches the router. `Space` and `Escape` therefore stay live in the moment after any click. diff --git a/src/sampletones_application/utils/gui/keyboard/focus/consumption.py b/src/sampletones_application/utils/gui/keyboard/focus/consumption.py index faf379a5b..bfb597881 100644 --- a/src/sampletones_application/utils/gui/keyboard/focus/consumption.py +++ b/src/sampletones_application/utils/gui/keyboard/focus/consumption.py @@ -59,14 +59,15 @@ def field_consumes_key(kind: FieldKind, key: int, modifiers: ModifierSet) -> boo caret, commit, and cancel keys belong to whichever field is focused. A command chord, one held with Ctrl or Super, reaches the shortcuts, so Ctrl+Space plays and - Cmd+S saves from a field. A text-entry field keeps the text-edit chords alone: select all, + Cmd+S saves from a field. A field that types keeps the text-edit chords alone: select all, copy, cut, paste, undo and redo, spelled with Ctrl and with Super, the key macOS spells them with. Super decides first, so Cmd+Option+S is a command as well. A text-entry field also keeps an Alt chord on a key that types a character, because AltGr and Option type characters that way: Linux reports AltGr as Alt and Windows as Ctrl+Alt. An Alt - chord on a function, caret or editing key reaches the shortcuts, so Alt+F4 and Alt+Up stay - reachable while a field is focused. + chord on a function, caret or editing key reaches the shortcuts, so Alt+F4 stays reachable + while a field is focused. A number field types no such character, so every Alt chord reaches + the shortcuts from it. """ if kind is FieldKind.NONE: return False @@ -83,11 +84,11 @@ def field_consumes_key(kind: FieldKind, key: int, modifiers: ModifierSet) -> boo if key in EDITING_KEYS: return True - return kind is FieldKind.TEXT_ENTRY and key not in FUNCTION_KEYS + return kind.takes_typing and key not in FUNCTION_KEYS def _keeps_text_edit_chord(kind: FieldKind, key: int, modifiers: ModifierSet) -> bool: - return kind is FieldKind.TEXT_ENTRY and key in TEXT_EDIT_CHORDS.get( + return kind.takes_typing and key in TEXT_EDIT_CHORDS.get( modifiers, NO_KEYS, ) diff --git a/src/sampletones_application/utils/gui/keyboard/focus/items.py b/src/sampletones_application/utils/gui/keyboard/focus/items.py index d97dbd752..95b861d6a 100644 --- a/src/sampletones_application/utils/gui/keyboard/focus/items.py +++ b/src/sampletones_application/utils/gui/keyboard/focus/items.py @@ -2,9 +2,10 @@ from sampletones_application.utils.gui.keyboard.focus.kind import FieldKind -TEXT_ENTRY_ITEM_TYPES: Final[FrozenSet[str]] = frozenset( +TEXT_ENTRY_ITEM_TYPES: Final[FrozenSet[str]] = frozenset({"mvAppItemType::mvInputText"}) + +NUMBER_ENTRY_ITEM_TYPES: Final[FrozenSet[str]] = frozenset( { - "mvAppItemType::mvInputText", "mvAppItemType::mvInputInt", "mvAppItemType::mvInputFloat", "mvAppItemType::mvInputDouble", @@ -19,6 +20,7 @@ FIELD_KINDS: Final[Dict[str, FieldKind]] = { **{item_type: FieldKind.TEXT_ENTRY for item_type in TEXT_ENTRY_ITEM_TYPES}, + **{item_type: FieldKind.NUMBER_ENTRY for item_type in NUMBER_ENTRY_ITEM_TYPES}, **{item_type: FieldKind.CHOICE for item_type in CHOICE_ITEM_TYPES}, } diff --git a/src/sampletones_application/utils/gui/keyboard/focus/kind.py b/src/sampletones_application/utils/gui/keyboard/focus/kind.py index d7965f5b4..425dd20b8 100644 --- a/src/sampletones_application/utils/gui/keyboard/focus/kind.py +++ b/src/sampletones_application/utils/gui/keyboard/focus/kind.py @@ -4,10 +4,17 @@ class FieldKind(Enum): """What a focused widget does with a key press, which decides the keys it keeps for itself. - ``TEXT_ENTRY`` inserts typed characters (text and number inputs); ``CHOICE`` navigates a list - of options (an open combo); ``NONE`` is any other focus, which yields every key. + ``TEXT_ENTRY`` inserts any typed character (a text input). ``NUMBER_ENTRY`` inserts the + characters of a number (a number input, a slider or a drag being typed into). ``CHOICE`` + navigates a list of options (an open combo). ``NONE`` is any other focus, which yields every key. """ NONE = "none" TEXT_ENTRY = "text_entry" + NUMBER_ENTRY = "number_entry" CHOICE = "choice" + + @property + def takes_typing(self) -> bool: + """Whether the field inserts the characters typed into it.""" + return self in (FieldKind.TEXT_ENTRY, FieldKind.NUMBER_ENTRY) diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py index 3856df872..254293df6 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py @@ -7,6 +7,7 @@ from sampletones_application.utils.gui.keyboard.focus.consumption import ( field_consumes_key, ) +from sampletones_application.utils.gui.keyboard.focus.items import field_kind from sampletones_application.utils.gui.keyboard.focus.kind import FieldKind from sampletones_application.utils.gui.keyboard.modifiers import ( ALT, @@ -22,6 +23,7 @@ ) from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.unit.sampletones_application.utils.gui.keyboard.focus.item_tree import INPUT_INT, SLIDER_INT ALT_SHIFT: Final[ModifierSet] = frozenset({Modifier.ALT, Modifier.SHIFT}) SUPER_ALT: Final[ModifierSet] = frozenset({Modifier.SUPER, Modifier.ALT}) @@ -124,14 +126,14 @@ class TestCase(BaseRegularTestCase): expected=False, ), TestCase( - label="text field yields Alt on a caret key, so Alt+Up reaches its shortcut", + label="text field yields Alt on a caret key, Alt+Up", kind=FieldKind.TEXT_ENTRY, key=dpg.mvKey_Up, modifiers=ALT, expected=False, ), TestCase( - label="text field yields Alt on an editing key, so Alt+Home reaches its shortcut", + label="text field yields Alt on an editing key, Alt+Home", kind=FieldKind.TEXT_ENTRY, key=dpg.mvKey_Home, modifiers=ALT, @@ -241,6 +243,65 @@ class TestCase(BaseRegularTestCase): key=dpg.mvKey_F11, expected=False, ), + TestCase( + label="number field types a digit", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_2, + expected=True, + ), + TestCase( + label="number field types a minus sign", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_Minus, + expected=True, + ), + TestCase( + label="number field commits on Enter", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_Return, + expected=True, + ), + TestCase( + label="number field selects all on Ctrl+A", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_A, + modifiers=CTRL, + expected=True, + ), + TestCase( + label="number field pastes on Super+V", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_V, + modifiers=SUPER, + expected=True, + ), + TestCase( + label="number field yields Ctrl+S", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_S, + modifiers=CTRL, + expected=False, + ), + TestCase( + label="number field yields Ctrl+Alt+S, which types nothing in a number", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_S, + modifiers=CTRL_ALT, + expected=False, + ), + TestCase( + label="number field yields Alt+2, which types nothing in a number", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_2, + modifiers=ALT, + expected=False, + ), + TestCase( + label="number field yields F11", + kind=FieldKind.NUMBER_ENTRY, + key=dpg.mvKey_F11, + expected=False, + ), TestCase( label="open combo yields a plain space", kind=FieldKind.CHOICE, @@ -296,3 +357,26 @@ def test_a_ctrl_chord_letter_reaches_the_shortcut_when_shift_joins_it(self) -> N def test_redo_stays_with_the_field_as_a_ctrl_shift_chord(self) -> None: assert field_consumes_key(FieldKind.TEXT_ENTRY, dpg.mvKey_Z, CTRL_SHIFT) is True + + +class TestANumberFieldYieldsAltChords(BaseTestSuite): + """A number field types no character with AltGr or Option, so an Alt chord pressed in one reaches + its shortcut.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + item_type: str + modifiers: ModifierSet + + test_cases = ( + TestCase(label="Ctrl+Alt+S from an integer input", item_type=INPUT_INT, modifiers=CTRL_ALT), + TestCase(label="Alt+S from a slider", item_type=SLIDER_INT, modifiers=ALT), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_chord_reaches_its_shortcut(self, test_case: TestCase) -> None: + assert field_consumes_key(field_kind(test_case.item_type), dpg.mvKey_S, test_case.modifiers) is False diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_items.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_items.py index dfe4ba574..78b1e457f 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_items.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_items.py @@ -4,6 +4,7 @@ from sampletones_application.utils.gui.keyboard.focus.items import ( CHOICE_ITEM_TYPES, + NUMBER_ENTRY_ITEM_TYPES, TEXT_ENTRY_ITEM_TYPES, field_kind, reports_child_focus, @@ -40,14 +41,14 @@ class TestCase(BaseRegularTestCase): expected=FieldKind.TEXT_ENTRY, ), TestCase( - label="integer input types characters", + label="integer input types a number", item_type=INPUT_INT, - expected=FieldKind.TEXT_ENTRY, + expected=FieldKind.NUMBER_ENTRY, ), TestCase( - label="slider types characters", + label="slider types a number", item_type=SLIDER_INT, - expected=FieldKind.TEXT_ENTRY, + expected=FieldKind.NUMBER_ENTRY, ), TestCase( label="combo navigates options", @@ -84,11 +85,16 @@ class TestTaxonomyCoverage: def test_every_text_entry_type_maps_to_text_entry(self) -> None: assert {field_kind(item_type) for item_type in TEXT_ENTRY_ITEM_TYPES} == {FieldKind.TEXT_ENTRY} + def test_every_number_entry_type_maps_to_number_entry(self) -> None: + assert {field_kind(item_type) for item_type in NUMBER_ENTRY_ITEM_TYPES} == {FieldKind.NUMBER_ENTRY} + def test_every_choice_type_maps_to_choice(self) -> None: assert {field_kind(item_type) for item_type in CHOICE_ITEM_TYPES} == {FieldKind.CHOICE} - def test_the_two_field_taxonomies_stay_disjoint(self) -> None: - assert TEXT_ENTRY_ITEM_TYPES.isdisjoint(CHOICE_ITEM_TYPES) + def test_the_field_taxonomies_stay_disjoint(self) -> None: + taxonomies = (TEXT_ENTRY_ITEM_TYPES, NUMBER_ENTRY_ITEM_TYPES, CHOICE_ITEM_TYPES) + + assert sum(len(taxonomy) for taxonomy in taxonomies) == len(frozenset().union(*taxonomies)) class TestReportsChildFocus(BaseTestSuite): diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_search.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_search.py index 4aecd3b3b..2c9aa293e 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_search.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_search.py @@ -46,7 +46,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="actively edited integer input", items={FOCUSED: editing(INPUT_INT)}, - expected=FieldKind.TEXT_ENTRY, + expected=FieldKind.NUMBER_ENTRY, ), TestCase( label="open combo", From 5481bbca893c082a3f37e7d0866133ca115a55b5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:29:18 +0200 Subject: [PATCH 72/84] Fixed: a stale keybinding override dropping the reassignments stored before it, with the overrides now read together in any order --- docs/development/application/keyboard.md | 2 +- .../utils/gui/shortcuts/scheme.py | 112 ++++++++++-------- .../utils/gui/shortcuts/test_scheme.py | 60 +++++++++- 3 files changed, 123 insertions(+), 51 deletions(-) diff --git a/docs/development/application/keyboard.md b/docs/development/application/keyboard.md index de4bd8255..23721828f 100644 --- a/docs/development/application/keyboard.md +++ b/docs/development/application/keyboard.md @@ -64,7 +64,7 @@ One key table (`utils/gui/keyboard/keys.py`) reads a key both ways: the name a f ### A preference layers over the shipped scheme -`ShortcutsConfig` holds the scheme name and the per-action overrides, both written the way a keybinding file writes them, so a preference outlives the build that stored it. `ShortcutCatalog.select` answers with the default for a scheme a build stopped shipping. An override lists every key of its action, main key first and joined by commas, and a key spells the comma as `Comma`. A stored single key is therefore an action with one key. An override is reported and left out whole when it names an action this build does not have, a key the table does not have, or a combination its category already gives away. One stale entry therefore costs only itself, and its action keeps the scheme's keys. +`ShortcutsConfig` holds the scheme name and the per-action overrides, both written the way a keybinding file writes them, so a preference outlives the build that stored it. `ShortcutCatalog.select` answers with the default for a scheme a build stopped shipping. An override lists every key of its action, main key first and joined by commas, and a key spells the comma as `Comma`. A stored single key is therefore an action with one key. The overrides are read together, so entries that move keys between their actions stand in whatever order they were written. An override is reported and left out whole when it names an action this build does not have, a key the table does not have, or a combination another action of its category keeps. A stale entry therefore costs itself, and its action keeps the scheme's keys. A change reaches the running application through `ShortcutSource.on_bindings_changed`, the keyboard's analog of the palette switch ([`palette.md`](palette.md)). The dispatcher re-reads the keys, and the menus re-print their accelerators. Each registration names the action it fires, so a rebind has little to catch up. diff --git a/src/sampletones_application/utils/gui/shortcuts/scheme.py b/src/sampletones_application/utils/gui/shortcuts/scheme.py index 04a5f4720..2ff090425 100644 --- a/src/sampletones_application/utils/gui/shortcuts/scheme.py +++ b/src/sampletones_application/utils/gui/shortcuts/scheme.py @@ -6,7 +6,11 @@ from pydantic import BaseModel, model_validator -from sampletones_application.utils.gui.keyboard.combination import KeyCombination, parse_combinations +from sampletones_application.utils.gui.keyboard.combination import ( + KeyCombination, + display_combinations, + parse_combinations, +) from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.shortcuts.ids import ( SHORTCUT_IDS_BY_NAME, @@ -175,10 +179,11 @@ def with_overrides( """The scheme as a reader rebound it, each entry giving one action the keys it names. An override names its action the way a keybinding file writes it, which lets a preference - outlive the build that stored it, and lists the action's keys joined by commas. The set is - read at once, so entries that pass keys between them arrive together; where the whole leaves - the scheme unresolvable, the entries are read one at a time and each that stands aside costs - only itself. + outlive the build that stored it, and lists the action's keys joined by commas. The entries + are read together, in any order, so entries that pass keys between their actions all stand. + An entry naming an action or a key this build has none of is reported and left out, and so + is one giving its action a key another action of the category keeps. The action of an entry + left out keeps the scheme's keys. Args: overrides: The keys each rebound action answers to, keyed by the action's name. @@ -189,37 +194,7 @@ def with_overrides( if not overrides: return self - try: - return self.with_bindings(self._read_overrides(overrides)) - except (KeyError, SystemError) as exception: - logger.warning(f"Keybindings overrides read one entry at a time: {exception}") - return self._rebound_each(overrides) - - def rebound(self, name: str, keys: Optional[str]) -> ShortcutScheme: - """The scheme as one stored preference rebinds it, read the way a preference is read. - - An entry takes effect while it names an action this build carries, keys the table holds and - combinations its category has room for. Anything else is reported and the scheme is - returned as it stands, so one unreadable preference costs only itself, and its action keeps - every key the scheme gives it. - - Args: - name: The action the entry rebinds, named the way a keybinding file writes it. - keys: The keys it answers to, joined by commas; ``None`` leaves the action unbound. - - Returns: - ShortcutScheme: The scheme the entry leaves in place. - """ - shortcut_id = SHORTCUT_IDS_BY_NAME.get(name) - if shortcut_id is None: - logger.warning(f"Keybinding override names unknown action {name!r}, keeping the scheme's own keys") - return self - - try: - return self.with_binding(shortcut_id, self._read_keys(keys)) - except (KeyError, SystemError) as exception: - logger.warning(f"Keybinding override giving {name!r} the keys {keys!r} left out: {exception}") - return self + return self.with_bindings(self._standing_overrides(self._readable_overrides(overrides))) @classmethod def load(cls, path: Path) -> ShortcutScheme: @@ -239,17 +214,25 @@ def load(cls, path: Path) -> ShortcutScheme: return cls.model_validate(raw) - def _read_overrides( + def _readable_overrides( self, overrides: Mapping[str, Optional[str]], ) -> Dict[ShortcutId, Tuple[KeyCombination, ...]]: - """Every override as the action and the keys it names. + """Every override naming an action this build carries and keys the table holds, as that action + and its keys, each other override reported and left out.""" + readable: Dict[ShortcutId, Tuple[KeyCombination, ...]] = {} + for name, keys in overrides.items(): + shortcut_id = SHORTCUT_IDS_BY_NAME.get(name) + if shortcut_id is None: + logger.warning(f"Keybinding override names unknown action {name!r}, keeping the scheme's own keys") + continue - Raises: - KeyError: when an entry names an action this build carries none of, or a key the table - holds none of. - """ - return {SHORTCUT_IDS_BY_NAME[name]: self._read_keys(keys) for name, keys in overrides.items()} + try: + readable[shortcut_id] = self._read_keys(keys) + except KeyError as exception: + logger.warning(f"Keybinding override giving {name!r} the keys {keys!r} left out: {exception}") + + return readable @staticmethod def _read_keys(keys: Optional[str]) -> Tuple[KeyCombination, ...]: @@ -260,13 +243,44 @@ def _read_keys(keys: Optional[str]) -> Tuple[KeyCombination, ...]: """ return () if keys is None else parse_combinations(keys) - def _rebound_each(self, overrides: Mapping[str, Optional[str]]) -> ShortcutScheme: - """The scheme as every override that stands rebinds it, read one entry at a time.""" - scheme = self - for name, keys in overrides.items(): - scheme = scheme.rebound(name, keys) + def _standing_overrides( + self, readable: Dict[ShortcutId, Tuple[KeyCombination, ...]] + ) -> Dict[ShortcutId, Tuple[KeyCombination, ...]]: + """The overrides left once each one sharing a key with another action of its category is + reported and left out. + + An entry left out brings its action's keys back, which can meet the keys of another entry, so + the check runs again until every key reaches one action of its category. + """ + standing = dict(readable) + colliding = self._colliding_overrides(standing) + while colliding: + for shortcut_id in colliding: + logger.warning( + f"Keybinding override giving {shortcut_id.value!r} the keys " + f"{display_combinations(standing.pop(shortcut_id))!r} left out: " + f"another action of the {shortcut_id.category} category answers one of them" + ) + + colliding = self._colliding_overrides(standing) + + return standing + + def _colliding_overrides(self, overrides: Dict[ShortcutId, Tuple[KeyCombination, ...]]) -> Tuple[ShortcutId, ...]: + """The overrides answering a key another action of their category answers, every action not + overridden keeping the scheme's keys.""" + holders: Dict[Tuple[ShortcutCategory, KeyCombination], Dict[ShortcutId, None]] = {} + for shortcut_id in ShortcutId: + keys = overrides[shortcut_id] if shortcut_id in overrides else self.shortcut(shortcut_id).combinations() + for combination in keys: + holders.setdefault((shortcut_id.category, combination), {})[shortcut_id] = None + + colliding: Dict[ShortcutId, None] = {} + for actions in holders.values(): + if len(actions) > 1: + colliding.update(dict.fromkeys(shortcut_id for shortcut_id in actions if shortcut_id in overrides)) - return scheme + return tuple(colliding) def _require_every_action_answered(self) -> None: unanswered: List[str] = [shortcut_id.value for shortcut_id in ShortcutId if shortcut_id not in self.bindings] diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py index 084fe4980..3e0f3c80b 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -1,4 +1,6 @@ +import logging from pathlib import Path +from typing import List, Mapping, Optional import dearpygui.dearpygui as dpg import pytest @@ -6,7 +8,7 @@ from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME from sampletones_application.paths import KEYBINDINGS_DIRECTORY -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import KeyCombination, display_combinations from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.keyboard.modifiers import CTRL, CTRL_SHIFT from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId @@ -31,6 +33,12 @@ """ +def _reported(caplog: pytest.LogCaptureFixture, overrides: Mapping[str, Optional[str]]) -> List[str]: + """The overrides a warning names, in the order the overrides list them.""" + warnings = [record.getMessage() for record in caplog.records if record.levelno == logging.WARNING] + return [name for name in overrides if any(repr(name) in warning for warning in warnings)] + + def _press(text: str) -> KeyEvent: """The press a written combination names, as the router delivers it.""" combination = KeyCombination.parse(text) @@ -313,6 +321,56 @@ def test_overrides_passing_a_combination_between_two_actions_both_stand( assert scheme.action(ShortcutCategory.APPLICATION, _press("Ctrl+S")) is ShortcutId.ABOUT_DIALOG assert scheme.shortcut(ShortcutId.SAVE_PROJECT).combinations() == () + def test_a_pair_moving_a_key_stands_beside_a_stale_entry_and_the_stale_entry_alone_is_reported( + self, + shipped: ShortcutScheme, + caplog: pytest.LogCaptureFixture, + ) -> None: + """An editor writes the action taking a key ahead of the action giving it up, and a later build + can give a stored key to an action of its own. The pair still moves the key, and the stale + entry costs only itself.""" + taken = display_combinations(shipped.shortcut(ShortcutId.SAVE_PROJECT).combinations()) + held = display_combinations(shipped.shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_1).combinations()) + overrides = { + ShortcutId.ABOUT_DIALOG.value: taken, + ShortcutId.SAVE_PROJECT.value: None, + ShortcutId.KEYBOARD_SETTINGS.value: held, + } + caplog.set_level(logging.WARNING) + + scheme = shipped.with_overrides(overrides) + + assert ( + display_combinations(scheme.shortcut(ShortcutId.ABOUT_DIALOG).combinations()), + scheme.shortcut(ShortcutId.SAVE_PROJECT).combinations(), + scheme.shortcut(ShortcutId.KEYBOARD_SETTINGS) == shipped.shortcut(ShortcutId.KEYBOARD_SETTINGS), + ) == (taken, (), True) + assert _reported(caplog, overrides) == [ShortcutId.KEYBOARD_SETTINGS.value] + + def test_two_actions_trading_their_keys_stand_beside_a_stale_entry( + self, + shipped: ShortcutScheme, + ) -> None: + undo_keys = display_combinations(shipped.shortcut(ShortcutId.UNDO).combinations()) + redo_keys = display_combinations(shipped.shortcut(ShortcutId.REDO).combinations()) + held = display_combinations(shipped.shortcut(ShortcutId.TOGGLE_CHANNEL_PULSE_1).combinations()) + + scheme = shipped.with_overrides( + { + ShortcutId.UNDO.value: redo_keys, + ShortcutId.REDO.value: undo_keys, + ShortcutId.KEYBOARD_SETTINGS.value: held, + } + ) + + assert ( + scheme.shortcut(ShortcutId.UNDO).combinations(), + scheme.shortcut(ShortcutId.REDO).combinations(), + ) == ( + shipped.shortcut(ShortcutId.REDO).combinations(), + shipped.shortcut(ShortcutId.UNDO).combinations(), + ) + def test_a_scheme_without_overrides_is_the_one_it_started_as(self, shipped: ShortcutScheme) -> None: assert shipped.with_overrides({}) is shipped From a9a19da02d1b7aafbe4cb85c733f9b64381d706d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:34:32 +0200 Subject: [PATCH 73/84] Fixed: the keybindings editor keeping a key named twice, refusing a typed comma key, and taking an edit that changes nothing as one to store --- docs/development/application/keyboard.md | 2 +- .../utils/gui/keyboard/combination.py | 45 ++++++++-- .../utils/gui/shortcuts/draft.py | 29 +++++-- .../interface/keybindings/test_every_key.py | 13 +-- .../keybindings/test_rebinding_undo.py | 9 +- .../coordinators/test_keybindings.py | 82 ++++++++++++++++--- .../utils/gui/keyboard/test_combination.py | 63 +++++++++++++- .../utils/gui/shortcuts/test_draft.py | 42 +++++++++- 8 files changed, 246 insertions(+), 39 deletions(-) diff --git a/docs/development/application/keyboard.md b/docs/development/application/keyboard.md index 23721828f..ffbacbb4d 100644 --- a/docs/development/application/keyboard.md +++ b/docs/development/application/keyboard.md @@ -72,7 +72,7 @@ A change reaches the running application through `ShortcutSource.on_bindings_cha `ShortcutDraft` (`utils/gui/shortcuts/draft.py`) holds the scheme being edited together with the actions the reader has touched: every key each now answers, main key first, and none where it was left unbound. Only those actions reach the preference, and every other key follows the scheme beneath. -An edit changes the keys the reader names, and the action keeps the rest. A pressed key becomes the action's main key, the one the menus print, and the keys it had follow it. A list written in the entry box becomes exactly the action's keys. Clear unbinds every key. +An edit changes the keys the reader names, and the action keeps the rest. A pressed key becomes the action's main key, the one the menus print, and the keys it had follow it. A list written in the entry box becomes exactly the action's keys, each named once, and a comma written where a key goes is the comma key. Clear unbinds every key. An action left on the keys the scheme ships holds no edit, so a change that leaves the keys as they were gives the reader nothing to store or discard. An assignment displaces only what it takes. Giving an action keys its category already answers takes those keys from their holders in the same step, so every scheme a draft produces is valid. A holder keeps its other keys, the next one becoming its main key. The dialog names every holder in one question and asks before that step is taken. Cancel on that question brings the reader back to where they gave the keys: a written list to the entry box, and a pressed key to its row, listening for the next press. A row reads as listening exactly while it takes the keys. The dialog edits the draft, and a commit activates it, so a reader rebinding Escape, Tab or Enter keeps the keys the dialog is operated by until they are done. diff --git a/src/sampletones_application/utils/gui/keyboard/combination.py b/src/sampletones_application/utils/gui/keyboard/combination.py index 985aa1c5a..92ed994b9 100644 --- a/src/sampletones_application/utils/gui/keyboard/combination.py +++ b/src/sampletones_application/utils/gui/keyboard/combination.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final, Iterable, Set, Tuple +from typing import Final, Iterable, List, Set, Tuple from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.keyboard.keys import ( @@ -91,19 +91,52 @@ def parse(cls, text: str) -> KeyCombination: def combination_parts(text: str) -> Tuple[str, ...]: """The combinations a written list such as ``"Ctrl+Y, Ctrl+Shift+Z"`` names, each as it is written. - The list is split at its commas and each part is trimmed, and a blank part names nothing. A key - spells the comma as ``Comma``, which keeps the comma free to separate the combinations. + A comma separates two combinations, and a comma written where a key goes is the comma key: one + after the modifiers of a combination, as in ``"Ctrl+,"``, and one standing alone before a + separator or the end of the list. Each part is trimmed, and a blank part names nothing. A list + is displayed with the comma key spelled ``Comma``, so a displayed list reads back as itself. """ - return tuple(part.strip() for part in text.split(KEY_LIST_SEPARATOR) if part.strip()) + parts: List[str] = [] + part = "" + for index, character in enumerate(text): + if character == KEY_LIST_SEPARATOR and not _is_comma_key(part, text[index + 1 :]): + parts.append(part) + part = "" + else: + part += character + + parts.append(part) + return tuple(part.strip() for part in parts if part.strip()) + + +def _is_comma_key(written: str, rest: str) -> bool: + """Whether a comma after ``written`` and ahead of ``rest`` in one part of a list is a key.""" + before = written.strip() + if before: + return _awaits_key(before) + + after = rest.lstrip() + return not after or after.startswith(KEY_LIST_SEPARATOR) + + +def _awaits_key(written: str) -> bool: + """Whether ``written`` names modifiers alone, each followed by the separator, so its key comes next.""" + if not written.endswith(COMBINATION_SEPARATOR): + return False + + return all( + part.casefold() in MODIFIER_NAMES + for part in written.removesuffix(COMBINATION_SEPARATOR).split(COMBINATION_SEPARATOR) + ) def parse_combinations(text: str) -> Tuple[KeyCombination, ...]: - """The combinations a written list names, in the order it names them. + """The combinations a written list names, in the order it names them, each one once. Raises: KeyError: If a part of the list names no key. """ - return tuple(KeyCombination.parse(part) for part in combination_parts(text)) + return tuple(dict.fromkeys(KeyCombination.parse(part) for part in combination_parts(text))) def display_combinations(combinations: Iterable[KeyCombination]) -> str: diff --git a/src/sampletones_application/utils/gui/shortcuts/draft.py b/src/sampletones_application/utils/gui/shortcuts/draft.py index 297eef21c..f7851bcae 100644 --- a/src/sampletones_application/utils/gui/shortcuts/draft.py +++ b/src/sampletones_application/utils/gui/shortcuts/draft.py @@ -16,9 +16,10 @@ class ShortcutDraft: An editor works on a draft and hands a scheme over once, which leaves the keys in force steady while Escape, Tab and Enter are themselves being rebound. A draft is kept as the actions the - reader touched and the whole list of keys each answers, main key first, since an override - replaces a whole binding: every other action answers the scheme the build ships, and the touched - entries are what a session stores. An empty list leaves an action unbound. + reader moved off the keys the scheme ships and the whole list of keys each answers, main key + first, since an override replaces a whole binding: every other action answers the scheme the + build ships, and the moved entries are what a session stores. An empty list leaves an action + unbound. """ base: ShortcutScheme @@ -137,7 +138,8 @@ def assign( A holder gives up the keys taken and keeps the rest, its next key becoming its main key, which keeps every scheme a draft produces valid, since one combination reaches one action within a category. An edit is held to the keys the table names, which is what lets every - draft be written down and read back. + draft be written down and read back. An action left on the keys the scheme ships records no + edit, so an assignment that changes nothing leaves the draft as it was. Args: shortcut_id: The action given the keys. @@ -152,12 +154,27 @@ def assign( if not combination.is_writable: raise KeyError(f"The key {combination.key} carries no name a binding is written under") - edits: Dict[ShortcutId, Keys] = {**self.edits, shortcut_id: given} + edits: Dict[ShortcutId, Keys] = dict(self.edits) + self._record(edits, shortcut_id, given) for holder in dict.fromkeys(self.holders(shortcut_id, given).values()): - edits[holder] = tuple(key for key in self.keys(holder) if key not in given) + self._record(edits, holder, tuple(key for key in self.keys(holder) if key not in given)) return replace(self, edits=edits) + def _record( + self, + edits: Dict[ShortcutId, Keys], + shortcut_id: ShortcutId, + keys: Keys, + ) -> None: + """Writes ``keys`` into ``edits`` as the keys an action answers, an action on the keys the + scheme ships recording no edit.""" + if keys == self.base.shortcut(shortcut_id).combinations(): + edits.pop(shortcut_id, None) + return + + edits[shortcut_id] = keys + def clear(self, shortcut_id: ShortcutId) -> ShortcutDraft: """The draft with an action left unbound, every key it held free for another action to take.""" return self.assign(shortcut_id, ()) diff --git a/tests/screens/interface/keybindings/test_every_key.py b/tests/screens/interface/keybindings/test_every_key.py index 703c67c5a..2c9cfedc2 100644 --- a/tests/screens/interface/keybindings/test_every_key.py +++ b/tests/screens/interface/keybindings/test_every_key.py @@ -5,7 +5,7 @@ import pytest from sampletones_application.categories.hierarchy import Tab -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import KeyCombination, display_combinations from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_core.constants.enums import ChannelName from tests.suite.screens.application.startup import Startup @@ -17,7 +17,6 @@ TOGGLE_PULSE_TWO: Final[ShortcutId] = ShortcutId.TOGGLE_CHANNEL_PULSE_2 NEW_MAIN_KEY: Final[KeyCombination] = KeyCombination.parse("Ctrl+Shift+2") -KEY_LIST_SEPARATOR: Final[str] = ", " SETTLING_FRAMES: Final[int] = 20 @@ -27,10 +26,6 @@ def startup() -> Startup: return Startup(reconstruction=None, project=ARRANGED_PROJECT) -def written_out(combinations: List[KeyCombination]) -> str: - return KEY_LIST_SEPARATOR.join(combination.display() for combination in combinations) - - class TestEditingARowKeepsEveryKey: """A channel's row lists its main key and its chord, and a new main key keeps both. @@ -49,7 +44,7 @@ def the_row_shows_every_key(screen: Screen) -> None: screen.expect(settings.is_shown, bool, description="Keyboard settings") assert len(shipped) > 1 - assert settings.keys_of(TOGGLE_PULSE_TWO) == written_out(shipped) + assert settings.keys_of(TOGGLE_PULSE_TWO) == display_combinations(shipped) def a_pressed_key_leads_the_others(screen: Screen) -> None: settings.listen_for(TOGGLE_PULSE_TWO) @@ -58,7 +53,7 @@ def a_pressed_key_leads_the_others(screen: Screen) -> None: screen.expect( partial(settings.keys_of, TOGGLE_PULSE_TWO), - written_out([NEW_MAIN_KEY, *shipped]).__eq__, + display_combinations([NEW_MAIN_KEY, *shipped]).__eq__, description="the new key ahead of the others", ) settings.confirm() @@ -90,7 +85,7 @@ def leaving_writes_every_key(screen: Screen) -> None: assert screen.wait_for_exit() assert written_application_config().shortcuts.overrides == { - TOGGLE_PULSE_TWO.value: written_out([NEW_MAIN_KEY, *shipped]), + TOGGLE_PULSE_TWO.value: display_combinations([NEW_MAIN_KEY, *shipped]), } screen.scenario( diff --git a/tests/screens/interface/keybindings/test_rebinding_undo.py b/tests/screens/interface/keybindings/test_rebinding_undo.py index 29d0ca5ba..a328602fe 100644 --- a/tests/screens/interface/keybindings/test_rebinding_undo.py +++ b/tests/screens/interface/keybindings/test_rebinding_undo.py @@ -7,7 +7,11 @@ 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, parse_combinations +from sampletones_application.utils.gui.keyboard.combination import ( + KeyCombination, + display_combinations, + parse_combinations, +) from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from tests.suite.screens.application.startup import Startup from tests.suite.screens.dearpygui.items.reading import read_item @@ -21,7 +25,6 @@ NEW_UNDO: Final[KeyCombination] = KeyCombination.parse("Ctrl+Alt+U") RENAMED: Final[str] = "Renamed" SETTLING_FRAMES: Final[int] = 20 -KEY_LIST_SEPARATOR: Final[str] = ", " @pytest.fixture @@ -88,7 +91,7 @@ def rebind_undo(screen: Screen) -> None: screen.expect( partial(settings.keys_of, ShortcutId.UNDO), - f"{NEW_UNDO.display()}{KEY_LIST_SEPARATOR}{original[ShortcutId.UNDO]}".__eq__, + display_combinations((NEW_UNDO, *parse_combinations(original[ShortcutId.UNDO]))).__eq__, description="the new keys ahead of the old ones", ) settings.write_keys(ShortcutId.UNDO, NEW_UNDO.display()) diff --git a/tests/unit/sampletones_application/coordinators/test_keybindings.py b/tests/unit/sampletones_application/coordinators/test_keybindings.py index d1b037b3f..4aa92485d 100644 --- a/tests/unit/sampletones_application/coordinators/test_keybindings.py +++ b/tests/unit/sampletones_application/coordinators/test_keybindings.py @@ -2,10 +2,16 @@ import pytest +from sampletones_application.categories.elements.settings import KeybindingActionElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.coordinators.keybindings import KeybindingsCoordinator from sampletones_application.paths import LANG_EN -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import ( + KEY_LIST_JOINER, + KeyCombination, + display_combinations, +) from sampletones_application.utils.gui.shortcuts.ids import ( EDITABLE_SHORTCUT_CATEGORIES, ShortcutId, @@ -27,10 +33,32 @@ UNDO_COMBINATION: Final[str] = "Ctrl+Z" REDO_COMBINATION: Final[str] = "Ctrl+Y" FREE_COMBINATION: Final[str] = "Ctrl+Alt+B" +SECOND_FREE_COMBINATION: Final[str] = "Ctrl+Alt+N" +COMMA_COMBINATION: Final[str] = "Ctrl+," UNREADABLE_COMBINATION: Final[str] = "Ctrl+Nonsense" -FREE_KEYS: Final[str] = "Ctrl+Alt+B, Ctrl+Alt+N" +FREE_KEYS: Final[str] = KEY_LIST_JOINER.join((FREE_COMBINATION, SECOND_FREE_COMBINATION)) +NO_KEYS: Final[str] = "" TWO_HOLDERS: Final[Tuple[ShortcutId, ...]] = (ShortcutId.SAVE_PROJECT, ShortcutId.OPEN_PROJECT) -TWO_HOLDER_LABELS: Final[Tuple[str, ...]] = ("Save project", "Open project") + + +def _action_label(shortcut_id: ShortcutId) -> str: + """The name the editor lists an action under, read from the language file.""" + return LanguageManager(LANG_EN)[ + Page.SETTINGS, + Panel.KEYBINDINGS, + TextType.LABEL, + KeybindingActionElements[shortcut_id.name], + ] + + +def _unbound_action() -> str: + """An action the shipped scheme gives no keys.""" + scheme = shipped_scheme() + return next( + shortcut_id.value + for shortcut_id in ShortcutId + if shortcut_id.category in EDITABLE_SHORTCUT_CATEGORIES and not scheme.shortcut(shortcut_id).combinations() + ) class _SessionRecorder: @@ -557,8 +585,8 @@ def test_a_pressed_key_leads_and_the_other_keys_stay(self, harness: Harness) -> harness.select(REDO) harness.capture(FREE_COMBINATION) - assert harness.row(REDO).keys == ", ".join( - (FREE_COMBINATION, *(combination.display() for combination in redo.combinations())) + assert harness.row(REDO).keys == display_combinations( + (KeyCombination.parse(FREE_COMBINATION), *redo.combinations()) ) def test_pressing_an_own_alias_moves_it_to_the_front_without_asking(self, harness: Harness) -> None: @@ -567,22 +595,22 @@ def test_pressing_an_own_alias_moves_it_to_the_front_without_asking(self, harnes harness.capture(redo.aliases[0].display()) assert harness.dialogs.confirmations == [] - assert harness.row(REDO).keys == ", ".join( - combination.display() for combination in (*redo.aliases, redo.combinations()[0]) - ) + assert harness.row(REDO).keys == display_combinations((*redo.aliases, redo.combinations()[0])) def test_a_typed_list_taking_keys_from_two_actions_asks_once_naming_both(self, harness: Harness) -> None: scheme = shipped_scheme() - taken = ", ".join(scheme.shortcut(shortcut_id).display() for shortcut_id in TWO_HOLDERS) + taken = display_combinations(scheme.shortcut(shortcut_id).combinations()[0] for shortcut_id in TWO_HOLDERS) harness.select(ABOUT_DIALOG) harness.type_combination(taken) assert len(harness.dialogs.confirmations) == 1 - assert all(label in harness.dialogs.confirmations[-1]["message"] for label in TWO_HOLDER_LABELS) + assert all( + _action_label(shortcut_id) in harness.dialogs.confirmations[-1]["message"] for shortcut_id in TWO_HOLDERS + ) def test_confirming_a_list_taken_from_two_actions_moves_both_keys(self, harness: Harness) -> None: scheme = shipped_scheme() - taken = ", ".join(scheme.shortcut(shortcut_id).display() for shortcut_id in TWO_HOLDERS) + taken = display_combinations(scheme.shortcut(shortcut_id).combinations()[0] for shortcut_id in TWO_HOLDERS) harness.select(ABOUT_DIALOG) harness.type_combination(taken) harness.dialogs.confirm() @@ -596,6 +624,36 @@ def test_clearing_unbinds_every_key(self, harness: Harness) -> None: assert harness.row(REDO).keys == "" + def test_an_empty_entry_unbinds_a_bound_action(self, harness: Harness) -> None: + harness.select(SAVE_PROJECT) + harness.type_combination(NO_KEYS) + harness.commit() + + assert dict(harness.session.writes)["overrides"] == {SAVE_PROJECT: None} + + def test_an_empty_entry_for_an_unbound_action_leaves_nothing_to_discard(self, harness: Harness) -> None: + harness.select(_unbound_action()) + harness.type_combination(NO_KEYS) + harness.cancel() + + assert (harness.dialogs.confirmations, harness.window.visible) == ([], False) + + def test_clearing_an_unbound_action_leaves_nothing_to_discard(self, harness: Harness) -> None: + harness.select(_unbound_action()) + harness.clear() + harness.cancel() + + assert (harness.dialogs.confirmations, harness.window.visible) == ([], False) + + def test_a_typed_comma_key_reaches_the_action(self, harness: Harness) -> None: + harness.select(ABOUT_DIALOG) + harness.type_combination(COMMA_COMBINATION) + + assert (harness.window.view_model.message, harness.row(ABOUT_DIALOG).keys) == ( + "", + KeyCombination.parse(COMMA_COMBINATION).display(), + ) + def test_a_list_is_stored_as_written(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) harness.type_combination(FREE_KEYS) @@ -605,7 +663,7 @@ def test_a_list_is_stored_as_written(self, harness: Harness) -> None: def test_a_list_naming_no_key_in_one_place_is_reported_and_the_keys_stand(self, harness: Harness) -> None: harness.select(SAVE_PROJECT) - harness.type_combination(f"{FREE_COMBINATION}, {UNREADABLE_COMBINATION}") + harness.type_combination(KEY_LIST_JOINER.join((FREE_COMBINATION, UNREADABLE_COMBINATION))) assert UNREADABLE_COMBINATION in harness.window.view_model.message assert harness.row(SAVE_PROJECT).keys == SAVE_COMBINATION diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py index 55fb9a1e5..e64210f4b 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py @@ -1,9 +1,15 @@ from dataclasses import dataclass +from typing import Final, Tuple import dearpygui.dearpygui as dpg import pytest -from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.combination import ( + KEY_LIST_JOINER, + KeyCombination, + display_combinations, + parse_combinations, +) from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.keyboard.keys import ( KEY_MODIFIER_ALT, @@ -23,6 +29,12 @@ from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +CTRL_COMMA: Final[KeyCombination] = KeyCombination(dpg.mvKey_Comma, CTRL) +CTRL_Y: Final[KeyCombination] = KeyCombination(dpg.mvKey_Y, CTRL) +CTRL_SHIFT_Z: Final[KeyCombination] = KeyCombination(dpg.mvKey_Z, CTRL_SHIFT) +COMMA: Final[KeyCombination] = KeyCombination(dpg.mvKey_Comma) +CTRL_PLUS: Final[KeyCombination] = KeyCombination(KEY_PLUS, CTRL) + WRITTEN_COMBINATIONS = ( "Ctrl+Shift+Z", "Ctrl+D", @@ -257,3 +269,52 @@ def test_a_spelling_reads_back_as_the_one_the_combination_displays_under( ) -> None: """A reader writes a combination however they know it and reads back one canonical form.""" assert KeyCombination.parse(written).display() == expected + + +class TestKeyLists(BaseTestSuite): + """A written list names several combinations apart by commas, and a comma written where a key goes + is the comma key.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + text: str + expected: Tuple[KeyCombination, ...] + + test_cases = ( + TestCase(label="nothing", text="", expected=()), + TestCase(label="one combination", text="Ctrl+Y", expected=(CTRL_Y,)), + TestCase(label="two combinations", text="Ctrl+Y, Ctrl+Shift+Z", expected=(CTRL_Y, CTRL_SHIFT_Z)), + TestCase(label="two combinations written tight", text="Ctrl+Y,Ctrl+Shift+Z", expected=(CTRL_Y, CTRL_SHIFT_Z)), + TestCase(label="a blank between two commas", text="Ctrl+Y,,Ctrl+Shift+Z", expected=(CTRL_Y, CTRL_SHIFT_Z)), + TestCase(label="the comma as a modified key", text="Ctrl+,", expected=(CTRL_COMMA,)), + TestCase(label="the comma written out", text="Ctrl+Comma", expected=(CTRL_COMMA,)), + TestCase( + label="the comma as a modified key ahead of another", text="Ctrl+,, Ctrl+Y", expected=(CTRL_COMMA, CTRL_Y) + ), + TestCase( + label="the comma as a modified key after another", text="Ctrl+Y, Ctrl+,", expected=(CTRL_Y, CTRL_COMMA) + ), + TestCase(label="the plus key ahead of another", text="Ctrl++, Ctrl+Y", expected=(CTRL_PLUS, CTRL_Y)), + TestCase(label="the comma alone", text=",", expected=(COMMA,)), + TestCase(label="the comma alone after another", text="Ctrl+Y, ,", expected=(CTRL_Y, COMMA)), + TestCase(label="a combination named twice", text="Ctrl+Y, Ctrl+Y", expected=(CTRL_Y,)), + TestCase(label="a combination spelled two ways", text="Ctrl+Y, ctrl+y", expected=(CTRL_Y,)), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_parse_combinations(self, test_case: TestCase) -> None: + assert parse_combinations(test_case.text) == test_case.expected + + def test_a_displayed_list_reads_back_as_itself(self) -> None: + combinations = (CTRL_COMMA, COMMA, CTRL_Y) + + assert parse_combinations(display_combinations(combinations)) == combinations + + def test_a_displayed_list_joins_its_combinations_by_the_joiner(self) -> None: + assert display_combinations((CTRL_Y, CTRL_SHIFT_Z)) == KEY_LIST_JOINER.join( + (CTRL_Y.display(), CTRL_SHIFT_Z.display()) + ) diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py index ade66f192..ce22b32f2 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py @@ -4,7 +4,11 @@ import dearpygui.dearpygui as dpg import pytest -from sampletones_application.utils.gui.keyboard.combination import KeyCombination, display_combinations +from sampletones_application.utils.gui.keyboard.combination import ( + KEY_LIST_JOINER, + KeyCombination, + display_combinations, +) from sampletones_application.utils.gui.keyboard.keys import ( KEY_DISPLAY_NAMES, KEY_MODIFIER_ALT, @@ -28,6 +32,11 @@ def keys(*written: str) -> Tuple[KeyCombination, ...]: return tuple(KeyCombination.parse(combination) for combination in written) +def unbound_action(draft: ShortcutDraft) -> ShortcutId: + """An action the scheme the draft opened on ships with no keys.""" + return next(shortcut_id for shortcut_id in ShortcutId if not draft.keys(shortcut_id)) + + @pytest.fixture def draft(shipped: ShortcutScheme) -> ShortcutDraft: """A draft of the shipped scheme, opened on a session that stores no preference of its own.""" @@ -76,6 +85,14 @@ def test_a_stored_single_key_opens_as_an_action_with_that_key_alone(self, shippe assert draft.keys(ShortcutId.REDO) == keys(FREE_COMBINATION) + def test_a_stored_list_naming_a_key_twice_opens_and_stores_it_once(self, shipped: ShortcutScheme) -> None: + draft = ShortcutDraft.open(shipped, {"Redo": f"{FREE_COMBINATION}{KEY_LIST_JOINER}{FREE_COMBINATION}"}) + + assert (draft.keys(ShortcutId.REDO), draft.overrides()) == ( + keys(FREE_COMBINATION), + {"Redo": FREE_COMBINATION}, + ) + def test_a_stored_list_opens_as_every_key_it_names(self, shipped: ShortcutScheme) -> None: draft = ShortcutDraft.open(shipped, {"Redo": f"{FREE_COMBINATION}, {SECOND_FREE_COMBINATION}"}) @@ -280,6 +297,24 @@ def test_an_action_given_the_keys_it_already_answers_keeps_them(self, draft: Sho assert edited.keys(ShortcutId.UNDO) == keys("Ctrl+Z") + def test_an_action_given_the_keys_it_already_answers_records_nothing(self, draft: ShortcutDraft) -> None: + edited = draft.assign(ShortcutId.UNDO, draft.keys(ShortcutId.UNDO)) + + assert (edited.is_dirty, edited.overrides()) == (False, {}) + + def test_an_unbound_action_given_no_keys_records_nothing(self, draft: ShortcutDraft) -> None: + edited = draft.assign(unbound_action(draft), ()) + + assert (edited.is_dirty, edited.overrides()) == (False, {}) + + def test_an_action_given_back_its_shipped_keys_records_nothing(self, draft: ShortcutDraft) -> None: + edited = draft.assign(ShortcutId.UNDO, keys(FREE_COMBINATION)).assign( + ShortcutId.UNDO, + draft.keys(ShortcutId.UNDO), + ) + + assert (edited.is_dirty, edited.overrides()) == (False, {}) + def test_a_list_taking_keys_from_two_actions_takes_each_from_its_holder( self, draft: ShortcutDraft, @@ -363,6 +398,11 @@ def test_it_reaches_no_action_of_the_scope(self, test_case: TestCase, draft: Sho class TestClear: + def test_clearing_an_unbound_action_records_nothing(self, draft: ShortcutDraft) -> None: + edited = draft.clear(unbound_action(draft)) + + assert (edited.is_dirty, edited.overrides()) == (False, {}) + def test_a_cleared_action_stores_as_unbound(self, draft: ShortcutDraft) -> None: edited = draft.clear(ShortcutId.UNDO) From e917ecd0a1338bfc838d791e013ecdbd038704bc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:34:40 +0200 Subject: [PATCH 74/84] Changed: the guards waiting for the screen through the dialogs renderer they are given --- docs/development/application/dialogs.md | 8 ++--- .../coordinators/project.py | 5 ++-- .../coordinators/reconstruction.py | 5 ++-- .../coordinators/tabs/instructions.py | 3 +- .../coordinators/tabs/main.py | 3 +- .../utils/gui/dialogs/renderer.py | 12 +++++++- tests/suite/questions.py | 7 +++++ .../coordinators/tabs/test_instructions.py | 15 ++++++---- .../coordinators/tabs/test_main.py | 15 ++++++---- .../coordinators/test_project.py | 15 ++++++---- .../coordinators/test_reconstruction.py | 17 +++++++---- .../utils/gui/dialogs/test_renderer.py | 29 +++++++++++++++++++ 12 files changed, 98 insertions(+), 36 deletions(-) create mode 100644 tests/unit/sampletones_application/utils/gui/dialogs/test_renderer.py diff --git a/docs/development/application/dialogs.md b/docs/development/application/dialogs.md index f0e557e66..2444502c8 100644 --- a/docs/development/application/dialogs.md +++ b/docs/development/application/dialogs.md @@ -64,10 +64,10 @@ through at once, whatever holds the screen, so closing the window over a dialog is unsaved. A turn that opens no question lets the line go on to the next window in the same frame. `ModalQueue` (`utils/gui/modal_queue.py`) keeps the line. `GUIWindow.show` is the only way a window enters -it, and `ModalQueue.when_free` is how a guard takes its turn, through `asking` (`utils/callbacks/gates.py`). -A caller raises a dialog whenever it has one to raise and never waits a frame of its own for the screen. A -window that reports work under way and leaves the rest of the interface live beside it is no modal, so it -opens at once. +it. A guard takes its turn through the `when_free` of the `DialogsRenderer` it was given, which `asking` +(`utils/callbacks/gates.py`) waits on. A caller raises a dialog whenever it has one to raise and never waits +a frame of its own for the screen. A window that reports work under way and leaves the rest of the interface +live beside it is no modal, so it opens at once. ## How a dialog answers diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 05afb2e21..db303bd1d 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -36,7 +36,6 @@ from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome -from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_core.exporters.skipped import SkippedRow from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.exports.backend import ExportBackend @@ -211,7 +210,7 @@ def ask(proceed: VoidCallback, decline: VoidCallback) -> None: ok_label=self._label(ok_label), ) - return asking(lambda: self.is_unsaved, ask, ModalQueue.when_free) + return asking(lambda: self.is_unsaved, ask, self._dialogs.when_free) def save(self) -> SaveOutcome: """Saves the project to its current file, prompting for one when it has none, and says so. @@ -435,7 +434,7 @@ def ask(proceed: VoidCallback, decline: VoidCallback) -> None: decline=decline, ) - asking(lambda: self._project_controller.is_open, ask, ModalQueue.when_free)(proceed, decline) + asking(lambda: self._project_controller.is_open, ask, self._dialogs.when_free)(proceed, decline) def _ask_before_replacing( self, diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 07a0f8f55..2a741f805 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -38,7 +38,6 @@ from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dialogs.outcome import SaveOutcome -from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_core.audio import AudioDeviceManager from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample @@ -258,7 +257,7 @@ def guard_load_converted( asking( self._requires_save_confirmation, partial(self._ask_before_loading_converted, filepath), - ModalQueue.when_free, + self._dialogs.when_free, )(proceed, decline) def _ask_before_loading_converted( @@ -608,7 +607,7 @@ def ask(proceed: VoidCallback, decline: VoidCallback) -> None: decline=decline, ) - asking(self._requires_save_confirmation, ask, ModalQueue.when_free)(proceed, decline) + asking(self._requires_save_confirmation, ask, self._dialogs.when_free)(proceed, decline) def _offer_save( self, diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index cc8b6ba2c..43fc2601e 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -67,7 +67,6 @@ from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.gui.frame import FrameCallbackManager -from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_application.view_model.instruction.data import InstructionPanelData from sampletones_application.view_model.instruction.details import ( InstructionDetailsPanelViewModel, @@ -518,7 +517,7 @@ def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: The question reads the library once the screen is free for it, so a build that ended meanwhile is asked about no more. Cancel keeps the library building and turns the exit away. """ - asking(self.is_library_generating, self._ask_before_exit, ModalQueue.when_free)(proceed, decline) + asking(self.is_library_generating, self._ask_before_exit, self._dialogs.when_free)(proceed, decline) def _ask_before_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: self._dialogs.show_confirmation( diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index b1859be67..e39c29ed6 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -65,7 +65,6 @@ from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter -from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_application.utils.gui.render_thread import on_render_thread from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.view_model.main.advanced import ( @@ -796,7 +795,7 @@ def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: The question reads the converter once the screen is free for it, so a run that ended meanwhile is asked about no more. Cancel keeps the conversion running and turns the exit away. """ - asking(self.is_converter_active, self._ask_before_exit, ModalQueue.when_free)(proceed, decline) + asking(self.is_converter_active, self._ask_before_exit, self._dialogs.when_free)(proceed, decline) def _ask_before_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: self._dialogs.show_confirmation( diff --git a/src/sampletones_application/utils/gui/dialogs/renderer.py b/src/sampletones_application/utils/gui/dialogs/renderer.py index 84e3081f1..6d2e61075 100644 --- a/src/sampletones_application/utils/gui/dialogs/renderer.py +++ b/src/sampletones_application/utils/gui/dialogs/renderer.py @@ -37,9 +37,10 @@ ) from sampletones_application.utils.gui.dpg import dpg_delete_item from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.modal_queue import ModalQueue from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.shortcuts.source import ShortcutSource -from sampletones_shared.types.callback import Callback, StringCallback +from sampletones_shared.types.callback import Callback, StringCallback, VoidCallback _TEMPLATE_PLACEHOLDER: Pattern[str] = re.compile(r"\{(\w+)\}") DIALOG_TEXT_MARGIN: Final[int] = 10 @@ -83,6 +84,15 @@ def __init__( self._lbl_cancel = language_manager["global.dialog.label.cancel"] self._lbl_save = language_manager["global.dialog.label.save"] + @staticmethod + def when_free(continuation: VoidCallback) -> None: + """Runs ``continuation`` once the screen is free for it, as a turn in the modal line. + + The signature is a :data:`Wait`, so a guard reads what it asks about once its question may + reach the screen. + """ + ModalQueue.when_free(continuation) + def show_modal( self, tag: str, diff --git a/tests/suite/questions.py b/tests/suite/questions.py index c906d5965..bcafa3507 100644 --- a/tests/suite/questions.py +++ b/tests/suite/questions.py @@ -35,6 +35,13 @@ def standing_window(held_frames: Frames) -> StandingWindow: return window +def dialogs_on_the_line() -> MagicMock: + """A stand-in for the dialogs renderer whose wait for the screen is the modal line's own turn.""" + dialogs = MagicMock() + dialogs.when_free.side_effect = ModalQueue.when_free + return dialogs + + @dataclass(frozen=True) class StandingQuestion: """A question on the screen, with the two ways out of it.""" diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py index c58bfa234..55e7c3c08 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py @@ -13,7 +13,12 @@ from sampletones_shared.exceptions import LibraryDisplayError from tests.suite.frames import held_frames from tests.suite.language import FakeLanguageManager -from tests.suite.questions import StandingWindow, assert_the_answers_reach, standing_window +from tests.suite.questions import ( + StandingWindow, + assert_the_answers_reach, + dialogs_on_the_line, + standing_window, +) __all__ = ["held_frames", "standing_window"] @@ -31,7 +36,7 @@ def _coordinator(state: LibraryState) -> InstructionsTabCoordinator: coordinator = InstructionsTabCoordinator.__new__(InstructionsTabCoordinator) coordinator._library_logic = MagicMock() coordinator._library_logic.config_library_state.return_value = state - coordinator._dialogs = MagicMock() + coordinator._dialogs = dialogs_on_the_line() coordinator._language_manager = FakeLanguageManager() return coordinator @@ -94,7 +99,7 @@ def _generation_coordinator( heavy constructor.""" coordinator = InstructionsTabCoordinator.__new__(InstructionsTabCoordinator) coordinator._is_converter_visible = lambda: converter_visible - coordinator._dialogs = MagicMock() + coordinator._dialogs = dialogs_on_the_line() coordinator._language_manager = FakeLanguageManager() coordinator._ttl_generation_status = GENERATION_STATUS_TITLE_KEY return coordinator @@ -126,7 +131,7 @@ def _remove_library_coordinator( coordinator = InstructionsTabCoordinator.__new__(InstructionsTabCoordinator) coordinator._library_logic = MagicMock() coordinator._library_logic.current_library_key = current_library_key - coordinator._dialogs = MagicMock() + coordinator._dialogs = dialogs_on_the_line() coordinator._instruction_player_logic = MagicMock() coordinator._on_audio_state_changed = MagicMock() coordinator._close_instruction = MagicMock() @@ -302,7 +307,7 @@ def _loaded_coordinator(*, display_error: Exception) -> InstructionsTabCoordinat coordinator._spectrum_panel = MagicMock() coordinator._instruction_player_logic = MagicMock() coordinator._instruction_details_logic = MagicMock() - coordinator._dialogs = MagicMock() + coordinator._dialogs = dialogs_on_the_line() coordinator._language_manager = FakeLanguageManager() coordinator._on_audio_state_changed = MagicMock() return coordinator diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index c28a7fc45..a27965c26 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -26,7 +26,12 @@ from tests.suite.files import LOCKED_FOLDER, held_at, requires_folder_permissions from tests.suite.frames import held_frames from tests.suite.language import FakeLanguageManager -from tests.suite.questions import StandingWindow, assert_the_answers_reach, standing_window +from tests.suite.questions import ( + StandingWindow, + assert_the_answers_reach, + dialogs_on_the_line, + standing_window, +) __all__ = ["held_frames", "standing_window"] @@ -69,7 +74,7 @@ def _coordinator(*, operation_active: bool, converting: bool = False) -> MainTab authority, which a conversion and every other exclusive operation answer.""" coordinator = MainTabCoordinator.__new__(MainTabCoordinator) coordinator._hooks = _hooks(operation_active=operation_active or converting) - coordinator._dialogs = MagicMock() + coordinator._dialogs = dialogs_on_the_line() coordinator._language_manager = FakeLanguageManager() coordinator._session_manager = MagicMock() coordinator._converter_logic = MagicMock() @@ -236,7 +241,7 @@ def test_a_key_reaches_the_pick_only_while_the_list_answers_keys(self, keys_acti def _success_coordinator() -> MainTabCoordinator: coordinator = MainTabCoordinator.__new__(MainTabCoordinator) - coordinator._dialogs = MagicMock() + coordinator._dialogs = dialogs_on_the_line() coordinator._hooks = _hooks(operation_active=False) coordinator._converter_logic = MagicMock() coordinator._language_manager = FakeLanguageManager() @@ -281,7 +286,7 @@ class TestCancelConfirmation: def test_cancel_request_confirms_before_stopping(self) -> None: coordinator = MainTabCoordinator.__new__(MainTabCoordinator) - coordinator._dialogs = MagicMock() + coordinator._dialogs = dialogs_on_the_line() coordinator._converter_logic = MagicMock() coordinator._language_manager = FakeLanguageManager() @@ -314,7 +319,7 @@ def _stems_coordinator( ) -> MainTabCoordinator: coordinator = MainTabCoordinator.__new__(MainTabCoordinator) coordinator._hooks = _hooks(operation_active=operation_active) - coordinator._dialogs = MagicMock() + coordinator._dialogs = dialogs_on_the_line() coordinator._language_manager = FakeLanguageManager() coordinator._converter_logic = MagicMock() coordinator._converter_logic.live = True diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index f1912afee..056170386 100644 --- a/tests/unit/sampletones_application/coordinators/test_project.py +++ b/tests/unit/sampletones_application/coordinators/test_project.py @@ -34,7 +34,12 @@ from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.frames import held_frames -from tests.suite.questions import StandingWindow, assert_the_answers_reach, standing_window +from tests.suite.questions import ( + StandingWindow, + assert_the_answers_reach, + dialogs_on_the_line, + standing_window, +) from tests.suite.silent_rows import MISSING_VOICE_ID, SILENT_CHANNEL __all__ = ["held_frames", "standing_window"] @@ -52,7 +57,7 @@ def project_coordinator() -> ProjectCoordinator: MagicMock(), export_backends={}, format_setups={}, - dialogs=MagicMock(), + dialogs=dialogs_on_the_line(), language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), @@ -95,7 +100,7 @@ def starting_fixture(self, tmp_path: Path) -> ProjectCoordinator: MagicMock(), export_backends={}, format_setups={}, - dialogs=MagicMock(), + dialogs=dialogs_on_the_line(), language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), @@ -270,7 +275,7 @@ def coordinator(self, setup: MagicMock) -> ProjectCoordinator: MagicMock(), export_backends={ExportFormat.FAMITRACKER: backend}, format_setups={ExportFormat.NSF: setup}, - dialogs=MagicMock(), + dialogs=dialogs_on_the_line(), language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), @@ -324,7 +329,7 @@ def coordinator_fixture() -> ProjectCoordinator: MagicMock(), export_backends={}, format_setups={}, - dialogs=MagicMock(), + dialogs=dialogs_on_the_line(), language_manager=LanguageManager(LANG_EN), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index 7af0aefc9..121f35bbe 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -51,7 +51,12 @@ from tests.suite.case import BaseRegularTestCase from tests.suite.frames import held_frames from tests.suite.language import FakeLanguageManager -from tests.suite.questions import StandingWindow, assert_the_answers_reach, standing_window +from tests.suite.questions import ( + StandingWindow, + assert_the_answers_reach, + dialogs_on_the_line, + standing_window, +) from tests.suite.stems import ( SHARED_CHANNEL, SHARED_OWNERS, @@ -182,7 +187,7 @@ def reconstruction_coordinator() -> ReconstructionCoordinator: MagicMock(), MagicMock(), MagicMock(), - dialogs=MagicMock(), + dialogs=dialogs_on_the_line(), language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), @@ -202,7 +207,7 @@ def _gating_coordinator( MagicMock(), MagicMock(), MagicMock(), - dialogs=MagicMock(), + dialogs=dialogs_on_the_line(), language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), @@ -298,7 +303,7 @@ def recording_coordinator( MagicMock(), MagicMock(), MagicMock(), - dialogs=MagicMock(), + dialogs=dialogs_on_the_line(), language_manager=MagicMock(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), @@ -689,7 +694,7 @@ def write_back(edit: ReconstructionEdit) -> None: MagicMock(), project_controller, history, - dialogs=MagicMock(), + dialogs=dialogs_on_the_line(), language_manager=FakeLanguageManager({}), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), @@ -1348,7 +1353,7 @@ def _coordinator( MagicMock(), MagicMock(), MagicMock(), - dialogs=MagicMock(), + dialogs=dialogs_on_the_line(), language_manager=FakeLanguageManager(), on_tab_switch=MagicMock(), on_session_state_changed=MagicMock(), diff --git a/tests/unit/sampletones_application/utils/gui/dialogs/test_renderer.py b/tests/unit/sampletones_application/utils/gui/dialogs/test_renderer.py new file mode 100644 index 000000000..010e66430 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/dialogs/test_renderer.py @@ -0,0 +1,29 @@ +from typing import Final, List + +from sampletones_application.utils.gui.dialogs import DialogsRenderer +from tests.suite.frames import held_frames +from tests.suite.questions import StandingWindow, standing_window + +__all__ = ["held_frames", "standing_window"] + +TURN: Final[str] = "turn" + + +class TestTheWaitForTheScreen: + """A guard waits for the screen through the renderer, as a turn in the modal line.""" + + def test_a_turn_runs_at_once_on_a_free_screen(self) -> None: + turns: List[str] = [] + + DialogsRenderer.when_free(lambda: turns.append(TURN)) + + assert turns == [TURN] + + def test_a_turn_runs_once_the_standing_window_leaves(self, standing_window: StandingWindow) -> None: + turns: List[str] = [] + + DialogsRenderer.when_free(lambda: turns.append(TURN)) + assert not turns + standing_window.leave() + + assert turns == [TURN] From 8e863b55b6e6691353b4cd86a0dd091e11fc91a2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:38:00 +0200 Subject: [PATCH 75/84] Moved: listing what a Reconstruct names and remembering its folder into the Main tab, leaving the application to bring the tab forward --- src/sampletones_application/application.py | 14 ++--- .../coordinators/tabs/hooks.py | 3 +- .../coordinators/tabs/main.py | 23 +++++++-- .../coordinators/tabs/test_main.py | 51 ++++++++++++------- 4 files changed, 58 insertions(+), 33 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 50d1f494e..a1a0bcef0 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -551,8 +551,7 @@ def __init__( hooks=MainTabHooks( is_operation_active=self._is_operation_active, on_busy_state_changed=self._refresh_busy_state, - on_reconstruct_file=self._reconstruct_file, - on_reconstruct_directory=self._reconstruct_directory, + on_reconstruct_listed=self._show_reconstruct_listing, on_load_reconstruction=self._reconstruction_opening, on_load_library=self._load_library, on_load_file=self._converted_loading_flight(), @@ -1145,9 +1144,8 @@ def _export_reconstruction_instruments_dialog(self, export_format: ExportFormat) if self._reconstruction_coordinator.check_loaded(): self._reconstructions_tab.request_export_instruments_dialog(export_format) - def _reconstruct_file(self, filepath: Path) -> None: - self._main_tab.take_up_path(filepath) - self.session_manager.set_audio_input_path(filepath.parent) + def _show_reconstruct_listing(self) -> None: + """Brings the Main tab forward on what a Reconstruct listed, with the menu following it.""" self._set_current_tab(Tab.MAIN) self._update_menu() @@ -1157,12 +1155,6 @@ def _load_library(self, filepath: Path) -> None: self._set_current_tab(Tab.INSTRUCTIONS) self._update_menu() - def _reconstruct_directory(self, directory_path: Path) -> None: - self._main_tab.take_up_path(directory_path) - self.session_manager.set_audio_input_path(directory_path) - self._set_current_tab(Tab.MAIN) - self._update_menu() - def _on_playback_error(self, exception: Exception) -> None: """Reports a playback the device refused, on the render thread whichever thread heard of it. diff --git a/src/sampletones_application/coordinators/tabs/hooks.py b/src/sampletones_application/coordinators/tabs/hooks.py index 3d31b2e4f..9fd912d43 100644 --- a/src/sampletones_application/coordinators/tabs/hooks.py +++ b/src/sampletones_application/coordinators/tabs/hooks.py @@ -17,8 +17,7 @@ class MainTabHooks: is_operation_active: Callable[[], bool] on_busy_state_changed: VoidCallback - on_reconstruct_file: PathCallback - on_reconstruct_directory: PathCallback + on_reconstruct_listed: VoidCallback on_load_reconstruction: Callable[[Optional[Path]], None] on_load_library: PathCallback on_load_file: PathCallback diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index b1859be67..f80215a06 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -442,11 +442,28 @@ def _choose_folder_to_reconstruct(self) -> None: @ignore_none_path def _reconstruct_file(self, filepath: Path) -> None: - self._giving_way_to_one_apiece(lambda: self._hooks.on_reconstruct_file(filepath)) + self._giving_way_to_one_apiece( + lambda: self._list_for_reconstruct( + filepath, + input_folder=filepath.parent, + ) + ) @ignore_none_path def _reconstruct_directory(self, directory_path: Path) -> None: - self._giving_way_to_one_apiece(lambda: self._hooks.on_reconstruct_directory(directory_path)) + self._giving_way_to_one_apiece( + lambda: self._list_for_reconstruct( + directory_path, + input_folder=directory_path, + ) + ) + + def _list_for_reconstruct(self, path: Path, *, input_folder: Path) -> None: + """Lists what a Reconstruct named, and keeps ``input_folder`` as the folder the next + Reconstruct dialog opens at.""" + self._take_up_path(path) + self._session_manager.set_audio_input_path(input_folder) + self._hooks.on_reconstruct_listed() def _giving_way_to_one_apiece(self, take_up: VoidCallback) -> None: """Takes up what a Reconstruct named, asking first about a mix it would replace. @@ -814,7 +831,7 @@ def is_converter_panel_visible(self) -> bool: def refresh_converter_view(self) -> None: self._converter_logic.refresh_view() - def take_up_path(self, path: Path) -> None: + def _take_up_path(self, path: Path) -> None: """Lists what a Reconstruct named, a recording or a folder, for the reader to convert one apiece. A folder is read before it is listed, and the reader watches the reading count what it finds. diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 531dce178..325250d50 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -49,8 +49,7 @@ def _hooks(*, operation_active: bool) -> MainTabHooks: return MainTabHooks( is_operation_active=lambda: operation_active, on_busy_state_changed=MagicMock(), - on_reconstruct_file=MagicMock(), - on_reconstruct_directory=MagicMock(), + on_reconstruct_listed=MagicMock(), on_load_reconstruction=MagicMock(), on_load_library=MagicMock(), on_load_file=MagicMock(), @@ -123,22 +122,22 @@ class TestCase(BaseRegularTestCase): TestCase( label="Reconstruct file... in the menu", door=_menu_reconstruct_file, - reached=lambda coordinator: coordinator._hooks.on_reconstruct_file.called, + reached=lambda coordinator: coordinator._hooks.on_reconstruct_listed.called, ), TestCase( label="Reconstruct folder... in the menu", door=_menu_reconstruct_directory, - reached=lambda coordinator: coordinator._hooks.on_reconstruct_directory.called, + reached=lambda coordinator: coordinator._hooks.on_reconstruct_listed.called, ), TestCase( label="Reconstruct file in the browser", door=MainTabCoordinator.request_reconstruct_file, - reached=lambda coordinator: coordinator._hooks.on_reconstruct_file.called, + reached=lambda coordinator: coordinator._hooks.on_reconstruct_listed.called, ), TestCase( label="Reconstruct directory in the browser", door=MainTabCoordinator.request_reconstruct_directory, - reached=lambda coordinator: coordinator._hooks.on_reconstruct_directory.called, + reached=lambda coordinator: coordinator._hooks.on_reconstruct_listed.called, ), TestCase( label="Add as stem, Ctrl-click or double-click on a recording", @@ -208,7 +207,7 @@ def test_replacing_the_mix_answered_after_a_run_started_lists_nothing(self) -> N coordinator._dialogs.show_confirmation.call_args.args[3]() - assert (_refused(coordinator), coordinator._hooks.on_reconstruct_file.called) == (True, False) + assert (_refused(coordinator), coordinator._hooks.on_reconstruct_listed.called) == (True, False) def test_a_mix_picked_after_a_run_started_changes_nothing(self) -> None: coordinator = _stems_coordinator(mixes=True, folder_rows=_rows_holding(MAX_STEM_SOURCES, 1)) @@ -316,6 +315,7 @@ def _stems_coordinator( coordinator._hooks = _hooks(operation_active=operation_active) coordinator._dialogs = MagicMock() coordinator._language_manager = FakeLanguageManager() + coordinator._session_manager = MagicMock() coordinator._converter_logic = MagicMock() coordinator._converter_logic.live = True coordinator._converter_logic.mixes = mixes @@ -503,7 +503,9 @@ class TestReconstructListsWhatItNames: """A Reconstruct lists what it names to convert one apiece, asking first only about a mix it replaces.""" def _coordinator(self, *, mixes: bool, gathered: Tuple[Path, ...] = ()) -> MainTabCoordinator: - return _stems_coordinator(mixes=mixes, gathered=gathered) + coordinator = _stems_coordinator(mixes=mixes, gathered=gathered) + coordinator._folder_scan = MagicMock() + return coordinator @pytest.mark.parametrize("gathered", [(), (Path("/audio/a.wav"),)]) def test_a_list_writing_one_apiece_takes_it_straight_away(self, tmp_path: Path, gathered: Tuple[Path, ...]) -> None: @@ -511,15 +513,31 @@ def test_a_list_writing_one_apiece_takes_it_straight_away(self, tmp_path: Path, coordinator.request_reconstruct_file(tmp_path / "a.wav") - coordinator._hooks.on_reconstruct_file.assert_called_once_with(tmp_path / "a.wav") + coordinator._converter_logic.take_up_recording.assert_called_once_with(tmp_path / "a.wav") + coordinator._hooks.on_reconstruct_listed.assert_called_once_with() coordinator._dialogs.show_confirmation.assert_not_called() + def test_a_recording_listed_leaves_its_folder_for_the_next_reconstruct_dialog(self, tmp_path: Path) -> None: + coordinator = self._coordinator(mixes=False) + + coordinator.request_reconstruct_file(tmp_path / "a.wav") + + coordinator._session_manager.set_audio_input_path.assert_called_once_with(tmp_path) + + def test_a_folder_listed_is_left_for_the_next_reconstruct_dialog(self, tmp_path: Path) -> None: + coordinator = self._coordinator(mixes=False) + + coordinator.request_reconstruct_directory(tmp_path) + + coordinator._session_manager.set_audio_input_path.assert_called_once_with(tmp_path) + def test_an_empty_mix_gives_way_straight_away(self, tmp_path: Path) -> None: coordinator = self._coordinator(mixes=True) coordinator.request_reconstruct_directory(tmp_path) - coordinator._hooks.on_reconstruct_directory.assert_called_once_with(tmp_path) + coordinator._folder_scan.start.assert_called_once_with(tmp_path, coordinator._take_up_read) + coordinator._hooks.on_reconstruct_listed.assert_called_once_with() coordinator._dialogs.show_confirmation.assert_not_called() def test_a_mix_holding_recordings_is_asked_about_first(self, tmp_path: Path) -> None: @@ -528,8 +546,7 @@ def test_a_mix_holding_recordings_is_asked_about_first(self, tmp_path: Path) -> coordinator.request_reconstruct_file(tmp_path / "b.wav") coordinator.request_reconstruct_directory(tmp_path) - coordinator._hooks.on_reconstruct_file.assert_not_called() - coordinator._hooks.on_reconstruct_directory.assert_not_called() + coordinator._hooks.on_reconstruct_listed.assert_not_called() prompts = [call.args[1] for call in coordinator._dialogs.show_confirmation.call_args_list] assert prompts == [DISCARD_STEMS_PROMPT_KEY, DISCARD_STEMS_PROMPT_KEY] @@ -539,7 +556,7 @@ def test_confirming_lists_what_was_named(self, tmp_path: Path) -> None: coordinator.request_reconstruct_directory(tmp_path) coordinator._dialogs.show_confirmation.call_args.args[3]() - coordinator._hooks.on_reconstruct_directory.assert_called_once_with(tmp_path) + coordinator._folder_scan.start.assert_called_once_with(tmp_path, coordinator._take_up_read) def test_declining_keeps_the_mix(self, tmp_path: Path) -> None: coordinator = self._coordinator(mixes=True, gathered=(Path("/audio/a.wav"),)) @@ -548,7 +565,7 @@ def test_declining_keeps_the_mix(self, tmp_path: Path) -> None: coordinator._dialogs.show_confirmation.call_args.kwargs["on_cancel"]() coordinator._converter_logic.set_output.assert_not_called() - coordinator._hooks.on_reconstruct_directory.assert_not_called() + coordinator._hooks.on_reconstruct_listed.assert_not_called() class TestTakingUpWhatAReconstructNamed: @@ -557,7 +574,7 @@ class TestTakingUpWhatAReconstructNamed: def test_a_recording_is_listed(self, tmp_path: Path) -> None: coordinator = _stems_coordinator(mixes=False) - coordinator.take_up_path(tmp_path / "take.wav") + coordinator._take_up_path(tmp_path / "take.wav") coordinator._converter_logic.take_up_recording.assert_called_once_with(tmp_path / "take.wav") coordinator._converter_logic.start_conversion.assert_not_called() @@ -566,7 +583,7 @@ def test_a_folder_is_listed_with_what_it_holds(self, tmp_path: Path) -> None: coordinator = _stems_coordinator(mixes=False) root = _folder_of(tmp_path, 2) - settled(lambda: coordinator.take_up_path(root)) + settled(lambda: coordinator._take_up_path(root)) listed, found = coordinator._converter_logic.take_up_folder.call_args.args assert listed == root @@ -577,7 +594,7 @@ def test_a_folder_holding_no_recordings_says_so(self, tmp_path: Path) -> None: coordinator = _stems_coordinator(mixes=False) root = _folder_of(tmp_path, 0) - settled(lambda: coordinator.take_up_path(root)) + settled(lambda: coordinator._take_up_path(root)) coordinator._converter_logic.take_up_folder.assert_not_called() assert coordinator._dialogs.show_info.call_args.args[1] == NOTHING_BELOW_KEY From 1d64f1a86af03bff43c2c320e362428dac7b4522 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:38:29 +0200 Subject: [PATCH 76/84] Fixed: the exit leaving with a project a standing dialog left unsaved after its question had already passed --- docs/development/application/dialogs.md | 9 +- src/sampletones_application/application.py | 17 +++- .../coordinators/reconstruction.py | 11 ++- .../utils/callbacks/gates.py | 25 +++++ .../test_application_exit.py | 99 ++++++++++++++++++- .../sampletones_application/test_startup.py | 2 +- .../utils/callbacks/test_gates.py | 57 +++++++++++ 7 files changed, 205 insertions(+), 15 deletions(-) diff --git a/docs/development/application/dialogs.md b/docs/development/application/dialogs.md index 2444502c8..c4f872274 100644 --- a/docs/development/application/dialogs.md +++ b/docs/development/application/dialogs.md @@ -59,9 +59,12 @@ the line. **A question reads what it asks about once the screen is free for it.** A guard with something to ask takes a turn in the line, and reads its state again when the line reaches that turn. The conversation that held the screen has settled what it changes by then. An exit asked for while Close project asks therefore -finds the project Discard closed, and asks nothing about it. A guard with nothing to ask lets the request -through at once, whatever holds the screen, so closing the window over a dialog leaves at once when nothing -is unsaved. A turn that opens no question lets the line go on to the next window in the same frame. +finds the project Discard closed, and asks nothing about it. A chain of several guards, such as the exit's, +waits for the screen as a whole while any of them has something to ask. Every guard then reads its state on +the free screen, so a project that Project properties left unsaved is asked about, though it was clean when +the window was closed. A request with nothing to ask goes on at once, whatever holds the screen, so closing +the window over a dialog leaves at once when nothing is unsaved. A turn that opens no question lets the line +go on to the next window in the same frame. `ModalQueue` (`utils/gui/modal_queue.py`) keeps the line. `GUIWindow.show` is the only way a window enters it. A guard takes its turn through the `when_free` of the `DialogsRenderer` it was given, which `asking` diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 50d1f494e..0fe18417a 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -144,6 +144,7 @@ LatestRequestFlight, gated, waiting, + waiting_for_the_screen, ) from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.fps import FPSTimer @@ -1640,12 +1641,15 @@ def _exit_flight(self) -> LatestRequestFlight[[]]: """The exit as one conversation, in which each owner of something unfinished asks in turn. The edits of the open reconstruction land first, so each question asks about what the - reader has drawn. A close asked for again while the questions stand is absorbed, and Cancel - on any of them ends the conversation. + reader has drawn. While any owner has something to ask, the questions wait for the screen, + and every owner reads what it holds there, so a dialog standing as the window was closed has + settled what it changes. A close asked for again while the questions stand is absorbed, and + Cancel on any of them ends the conversation. """ return LatestRequestFlight( ( waiting(self._reconstruction_coordinator.after_edits), + waiting_for_the_screen(self._has_anything_to_ask_before_exit, self.dialogs.when_free), self._project_coordinator.guard_exit, self._reconstruction_coordinator.guard_exit, self._main_tab.guard_exit, @@ -1654,6 +1658,15 @@ def _exit_flight(self) -> LatestRequestFlight[[]]: self._exit_application, ) + def _has_anything_to_ask_before_exit(self) -> bool: + """Whether any owner the exit asks holds something unfinished: unsaved work, a conversion or a library build.""" + return ( + self._project_coordinator.is_unsaved + or self._reconstruction_coordinator.is_unsaved_standalone() + or self._main_tab.is_converter_active() + or self._instructions_tab.is_library_generating() + ) + def _is_project_open(self) -> bool: return self.project_controller.is_open diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index 2a741f805..4c619b0c6 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -130,12 +130,13 @@ def is_unsaved(self) -> bool: def is_saveable(self) -> bool: return self._reconstruction_manager.is_file_backed - def _requires_save_confirmation(self) -> bool: - """Reports pending edits that a save prompt can resolve. + def is_unsaved_standalone(self) -> bool: + """Whether the open document has unsaved changes that a save prompt can resolve. A prompt is warranted only for a standalone reconstruction with unsaved changes. A project sample has no file of its own, and its edits belong to the project — closing or - replacing it loses nothing, so it needs no prompt. + replacing it loses nothing, so it needs no prompt. This is what every save prompt of the + document reads, the exit's included. """ return self.is_unsaved() and not self._reconstruction_manager.is_project_sample @@ -255,7 +256,7 @@ def guard_load_converted( the question leads the loading's conversation. """ asking( - self._requires_save_confirmation, + self.is_unsaved_standalone, partial(self._ask_before_loading_converted, filepath), self._dialogs.when_free, )(proceed, decline) @@ -607,7 +608,7 @@ def ask(proceed: VoidCallback, decline: VoidCallback) -> None: decline=decline, ) - asking(self._requires_save_confirmation, ask, self._dialogs.when_free)(proceed, decline) + asking(self.is_unsaved_standalone, ask, self._dialogs.when_free)(proceed, decline) def _offer_save( self, diff --git a/src/sampletones_application/utils/callbacks/gates.py b/src/sampletones_application/utils/callbacks/gates.py index 6049bce2a..2c07ec785 100644 --- a/src/sampletones_application/utils/callbacks/gates.py +++ b/src/sampletones_application/utils/callbacks/gates.py @@ -61,6 +61,31 @@ def ask_on_the_screen(go_on: VoidCallback, turn_away: VoidCallback) -> None: return gate +def waiting_for_the_screen( + unsettled: Callable[[], bool], + screen: Wait, +) -> Gate: + """A gate that holds a chain of questions for the screen while any of them has something to ask. + + With nothing unsettled the request goes on at once, whatever holds the screen. Otherwise it waits + for the screen, and every guard after the gate reads its state there. A dialog standing as the + request was made can change that state, such as Project properties leaving the project unsaved, + so a guard with nothing to ask at the request asks once the dialog has left. + + Args: + unsettled: Whether any guard after the gate has something to ask about. + screen: The wait for the screen, such as the modal line's turn. + """ + + def gate(proceed: VoidCallback, _decline: VoidCallback) -> None: + if unsettled(): + screen(proceed) + else: + proceed() + + return gate + + def _declining_on_failure( step: Gate, proceed: VoidCallback, diff --git a/tests/unit/sampletones_application/test_application_exit.py b/tests/unit/sampletones_application/test_application_exit.py index 570a153dd..c296ea9a4 100644 --- a/tests/unit/sampletones_application/test_application_exit.py +++ b/tests/unit/sampletones_application/test_application_exit.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from typing import Final, Iterator, List, Optional, Tuple -from unittest.mock import MagicMock, patch +from typing import Callable, Final, Iterator, List, Optional, Tuple +from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -12,7 +12,7 @@ from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.frames import Frames, held_frames -from tests.suite.questions import OnScreenDocument +from tests.suite.questions import OnScreenDocument, dialogs_on_the_line __all__ = ["held_frames"] @@ -24,10 +24,15 @@ CLOSE: Final[str] = "close" EXIT: Final[str] = "exit" JOB_REPORT: Final[str] = "job report" +PROJECT_PROPERTIES: Final[str] = "project properties" class Owner: - """One owner of something the exit asks about, answering the way a real prompt would.""" + """One owner of something the exit asks about, answering the way a real prompt would. + + It stands in for each of the four owners, so it answers each one's reading of whether it has + something to ask by whether it is unfinished. + """ def __init__(self, name: str, asked: List[str]) -> None: self.name = name @@ -54,6 +59,19 @@ def land(self) -> None: for gesture in gestures: gesture() + @property + def is_unsaved(self) -> bool: + return self.unfinished + + def is_unsaved_standalone(self) -> bool: + return self.unfinished + + def is_converter_active(self) -> bool: + return self.unfinished + + def is_library_generating(self) -> bool: + return self.unfinished + def guard_exit(self, proceed: VoidCallback, decline: VoidCallback) -> None: if not self.unfinished: proceed() @@ -93,6 +111,7 @@ def __init__(self) -> None: self.application._reconstruction_coordinator = self.owners[RECONSTRUCTION] self.application._main_tab = self.owners[CONVERSION] self.application._instructions_tab = self.owners[LIBRARY] + self.application.dialogs = dialogs_on_the_line() self.exit = MagicMock() self.application._exit_application = self.exit self.application._exiting = self.application._exit_flight() @@ -307,6 +326,11 @@ def __init__(self) -> None: self.application._main_tab.guard_exit.side_effect = self.documents[CONVERSION].guard(EXIT) self.application._instructions_tab = MagicMock() self.application._instructions_tab.guard_exit.side_effect = self.documents[LIBRARY].guard(EXIT) + type(project).is_unsaved = PropertyMock(side_effect=self.unfinished(PROJECT)) + reconstruction.is_unsaved_standalone.side_effect = self.unfinished(RECONSTRUCTION) + self.application._main_tab.is_converter_active.side_effect = self.unfinished(CONVERSION) + self.application._instructions_tab.is_library_generating.side_effect = self.unfinished(LIBRARY) + self.application.dialogs = dialogs_on_the_line() self.exit = MagicMock() self.application._exit_application = self.exit self.leave = self.application._exit_flight() @@ -318,6 +342,10 @@ def __init__(self) -> None: def close(self, name: str) -> LatestRequestFlight[[]]: return self.closing[name] + def unfinished(self, name: str) -> Callable[[], bool]: + """The owner's reading of whether it has something to ask, taken when the application reads it.""" + return lambda: self.documents[name].unfinished + @pytest.fixture(name="leaving") def leaving_fixture() -> LeavingOverAQuestion: @@ -438,6 +466,69 @@ def test_a_conversion_still_running_is_asked_about( leaving.exit.assert_not_called() +class TestAnExitAskedWhileAnEditingDialogStands: + """A dialog standing as the window is closed can leave something unsaved, so every owner reads what it holds + once the dialog has left. + + The reported case: Project properties stands over a clean project and an unsaved reconstruction, the window + is closed, and OK changes the title. The exit then asks about the project before the reconstruction. + """ + + @pytest.fixture(name="edited") + def edited_fixture(self, leaving: LeavingOverAQuestion, held_frames: Frames) -> LeavingOverAQuestion: + leaving.documents[RECONSTRUCTION].unfinished = True + ModalQueue.open(PROJECT_PROPERTIES, lambda: None) + leaving.leave() + + leaving.documents[PROJECT].unfinished = True + ModalQueue.leave(PROJECT_PROPERTIES) + held_frames.render() + return leaving + + def test_the_project_the_dialog_left_unsaved_is_asked_about_first(self, edited: LeavingOverAQuestion) -> None: + assert edited.asked == [f"{PROJECT} {EXIT}"] + edited.exit.assert_not_called() + + def test_the_exit_leaves_once_both_are_answered( + self, + edited: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + edited.documents[PROJECT].go_on() + held_frames.render() + edited.documents[RECONSTRUCTION].go_on() + held_frames.render() + + assert edited.asked == [f"{PROJECT} {EXIT}", f"{RECONSTRUCTION} {EXIT}"] + edited.exit.assert_called_once_with() + + def test_cancel_on_the_project_keeps_the_application_open( + self, + edited: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + edited.documents[PROJECT].cancel() + held_frames.render() + + assert edited.asked == [f"{PROJECT} {EXIT}"] + edited.exit.assert_not_called() + assert ModalQueue.snapshot().is_settled + + def test_a_dialog_that_changed_nothing_leaves_the_question_to_the_reconstruction( + self, + leaving: LeavingOverAQuestion, + held_frames: Frames, + ) -> None: + leaving.documents[RECONSTRUCTION].unfinished = True + ModalQueue.open(PROJECT_PROPERTIES, lambda: None) + leaving.leave() + + ModalQueue.leave(PROJECT_PROPERTIES) + held_frames.render() + + assert leaving.asked == [f"{RECONSTRUCTION} {EXIT}"] + + class TestTheFrameTheExitIsDecidedIn: """The work waiting on the render thread stays unrun once the exit is decided, so nothing the reader left behind starts after they chose to leave.""" diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 94620d37a..f2e669982 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -505,7 +505,7 @@ def test_embedded_reconstruction_needs_no_save_prompt_when_edited( app.reconstruction_manager.mark_updated() assert app._reconstruction_coordinator.is_unsaved() - assert not app._reconstruction_coordinator._requires_save_confirmation() + assert not app._reconstruction_coordinator.is_unsaved_standalone() def test_save_as_detaches_open_document_from_the_project( self, diff --git a/tests/unit/sampletones_application/utils/callbacks/test_gates.py b/tests/unit/sampletones_application/utils/callbacks/test_gates.py index 829adf691..c528a61d5 100644 --- a/tests/unit/sampletones_application/utils/callbacks/test_gates.py +++ b/tests/unit/sampletones_application/utils/callbacks/test_gates.py @@ -13,6 +13,7 @@ gated, pass_gates, waiting, + waiting_for_the_screen, ) from sampletones_shared.types.callback import VoidCallback from tests.suite.base import BaseTestSuite @@ -311,6 +312,62 @@ def going_on_then_failing(proceed: VoidCallback, _decline: VoidCallback) -> None assert reached == [ARRIVED] +class TestAChainWaitingForTheScreen: + """A chain of questions waits for the screen while any guard in it has something to ask, and every guard + reads its state once the screen is free.""" + + @pytest.fixture(name="screen") + def screen_fixture(self) -> HeldWait: + return HeldWait() + + def test_nothing_to_ask_goes_on_while_the_screen_is_taken(self, screen: HeldWait, reached: List[str]) -> None: + pass_gates( + (waiting_for_the_screen(lambda: False, screen), Guard("clean", reached, unfinished=False)), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + assert reached == ["clean", ARRIVED] + + def test_something_to_ask_holds_every_guard_until_the_screen_is_free( + self, + screen: HeldWait, + reached: List[str], + ) -> None: + pass_gates( + ( + waiting_for_the_screen(lambda: True, screen), + Guard("clean", reached, unfinished=False), + Guard("unfinished", reached, unfinished=True), + ), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + assert reached == [] + + def test_a_guard_clean_at_the_request_reads_its_state_on_the_free_screen( + self, + screen: HeldWait, + reached: List[str], + ) -> None: + """A dialog standing as the request was made left the first guard's thing unfinished, so it asks first.""" + clean = Guard("clean", reached, unfinished=False) + unfinished = Guard("unfinished", reached, unfinished=True) + pass_gates( + (waiting_for_the_screen(lambda: clean.unfinished or unfinished.unfinished, screen), clean, unfinished), + lambda: reached.append(ARRIVED), + lambda: reached.append(DECLINED), + ) + + clean.unfinished = True + screen.release() + + assert reached == ["clean"] + assert clean.is_asking + assert not unfinished.is_asking + + class RaisingOnce: """A gate that fails the first time it is reached and lets every later request through.""" From 7059b248ef69288a801a49358cc5ce0936cdab0c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:39:11 +0200 Subject: [PATCH 77/84] Corrected: docstrings and the stems document naming a missing accessor and keys a field keeps --- docs/development/application/stems.md | 2 +- src/sampletones_application/logic/reconstruction/data.py | 3 +++ src/sampletones_application/utils/gui/shortcuts/manager.py | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/development/application/stems.md b/docs/development/application/stems.md index de852b4eb..f5ea864d1 100644 --- a/docs/development/application/stems.md +++ b/docs/development/application/stems.md @@ -8,7 +8,7 @@ Five terms recur. The **record** is the per-frame account of which recording pla ## The recorded stems -A stems reconstruction records one source per entry, naming its recording and the file it was read from. The application reads the locations through `source_paths`: one path for a single source, a tuple for stems, and empty once the reconstruction is detached from its origin. The names stay whatever happens to the locations, so a detached document still says which recordings it was built from. +A stems reconstruction records one source per entry, naming its recording and the file it was read from. The application reads the locations through `Reconstruction.audio_filepath`, which `StemsData.paths` answers: a tuple holding one path per recording, and empty once the reconstruction is detached from its origin, as a project sample is. The names stay whatever happens to the locations, so a detached document still says which recordings it was built from. A document whose own file is removed in the Browser keeps the locations of its recordings. Opening the document loads the recorded stems through `load_recordings`, at the scale the conversion recorded for the whole set it read. Each stem therefore carries the level it held in the conversion's mix, a stem heard on its own sounds at that level, and it keeps that level once another recording leaves the document. A document written before the scale was recorded holds one recording, which reads at its own peak, as its conversion read it. The mix of them is the original audio the source toggle and the waveform offer, computed fresh on every load. A recorded stem that is absent or unreadable on this machine follows the single-source rule: the whole original is unavailable, the approximation stands on its own, and the application names the first missing path in its dialog. diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index adb9b4c62..b8277c9be 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -44,6 +44,9 @@ def from_reconstruction(cls, reconstruction: Reconstruction, *, name: str) -> Se holds, so changes propagate live. Such a sample lives only in memory, hence ``filepath`` is ``None`` and its display name is supplied by the caller (the project sample's name). + + A document whose file was removed in the Browser is wrapped the same way, under the name + it had. It keeps the locations of its recordings, so its original audio loads again from them. """ return cls._assemble( reconstruction, diff --git a/src/sampletones_application/utils/gui/shortcuts/manager.py b/src/sampletones_application/utils/gui/shortcuts/manager.py index 22ad15516..4a78c35eb 100644 --- a/src/sampletones_application/utils/gui/shortcuts/manager.py +++ b/src/sampletones_application/utils/gui/shortcuts/manager.py @@ -119,8 +119,8 @@ def _dispatch(self, event: KeyEvent) -> bool: """Fires the shortcut matching the event, yielding its key to a focused field that acts on it unless the shortcut is field-transparent. - A field keeps only the keys it uses, so a text field holds plain Space and its editing keys - while Ctrl+Space and Escape still reach playback and Stop from the same field. + A field keeps only the keys it uses, so a text field holds plain Space, Escape and its + editing keys while Ctrl+Space still reaches playback from the same field. """ for shortcut, callback in self._bindings_by_key.get(event.key, ()): if not shortcut.matches(event): From 9b3389b3dd9ea9ee096bbcdc2f4a8fae1e06bf29 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:39:33 +0200 Subject: [PATCH 78/84] Fixed: a playback worker a stop let go of reporting a late refusal while the song plays --- src/sampletones_core/audio/manager.py | 11 ++-- .../sampletones_core/audio/test_manager.py | 63 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index 5ebc0e436..7184cfecb 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -730,9 +730,9 @@ def _playback_worker( reports it, so whoever follows the playback reads it stopped. A stop waits a while for the worker and then lets it go, so a device slow to open can bring - the worker back after a newer play has begun. The worker acts for its own ``generation`` - alone: a refusal it meets then is left unreported, a stream it opens then closes unplayed, - and the newer playback stands as it was. + the worker back once its playback was stopped, or after a newer play has begun. The worker + acts for its own ``generation`` alone: a refusal it meets then is left unreported, a stream + it opens then closes unplayed, and the state stands as the stop or the newer play left it. Args: output: The device and rate in force when the playback was asked for. @@ -833,10 +833,13 @@ def stop(self) -> None: Stop playback and reset state. Signals the playback thread to stop, waits for it to terminate (up to 1 second), - and resets all playback state. + and resets all playback state. The stopped playback's generation ends here, so a worker + the wait lets go of acts for no playback: a refusal it meets later reaches nobody, and the + state stays as the stop left it. """ with self._lock: self._stop = True + self._generation += 1 self._resume_event.set() if self._playback_thread is not None: diff --git a/tests/unit/sampletones_core/audio/test_manager.py b/tests/unit/sampletones_core/audio/test_manager.py index 2b7ccf8f2..0a29faef5 100644 --- a/tests/unit/sampletones_core/audio/test_manager.py +++ b/tests/unit/sampletones_core/audio/test_manager.py @@ -324,6 +324,69 @@ def test_the_newer_worker_refused_reports_it( assert not manager.is_playing() +class TestAPlaybackAStopLetGo: + """A worker a stop let go of plays for nobody, so what its device does later reaches nothing. + + Stopping waits a while for the worker, and a device slow to open can keep it longer. A song taking the + device stops the manager's playback first, so the worker can meet a refusal while the song plays. + """ + + LENGTH: Final[int] = 8 + + @pytest.fixture(name="let_go") + def let_go_fixture(self) -> Tuple[AudioDeviceManager, Dict[str, Any]]: + """A stopped manager, and the arguments the worker of its stopped playback was started with.""" + manager = _manager() + manager._buffer_size = self.LENGTH + manager._position_callback = MagicMock() + manager._playback_thread = None + with patch("sampletones_core.audio.manager.threading.Thread") as thread: + manager.play(np.zeros(self.LENGTH, dtype=np.float32)) + manager.stop() + + manager._position_callback.reset_mock() + return manager, thread.call_args.kwargs["kwargs"] + + def test_a_refusal_it_meets_later_reports_nothing_and_resets_nothing( + self, + let_go: Tuple[AudioDeviceManager, Dict[str, Any]], + ) -> None: + manager, worker = let_go + manager._pyaudio.open.side_effect = OSError("the device is busy") + reported: List[Exception] = [] + manager.on_playback_error = reported.append + + manager._playback_worker(**worker) + + assert reported == [] + manager._position_callback.assert_not_called() + + def test_a_stream_it_opens_later_closes_unplayed(self, let_go: Tuple[AudioDeviceManager, Dict[str, Any]]) -> None: + manager, worker = let_go + stream = MagicMock() + manager._pyaudio.open.return_value = stream + + manager._playback_worker(**worker) + + stream.write.assert_not_called() + stream.close.assert_called_once_with() + manager._position_callback.assert_not_called() + + def test_a_playback_started_after_the_stop_stands(self, let_go: Tuple[AudioDeviceManager, Dict[str, Any]]) -> None: + """The worker let go of leaves the playback asked for after it as it is.""" + manager, worker = let_go + manager._pyaudio.open.side_effect = OSError("the device is busy") + reported: List[Exception] = [] + manager.on_playback_error = reported.append + with patch("sampletones_core.audio.manager.threading.Thread"): + manager.play(np.ones(self.LENGTH, dtype=np.float32)) + + manager._playback_worker(**worker) + + assert manager.is_playing() + assert reported == [] + + class TestSeekingAPlayback(BaseTestSuite): """A seek moves the playback of the owner asking for it, clamped to the audio, under one lock.""" From 65577d30a1e27734b6f0e9aee132b581767b8bb5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:42:26 +0200 Subject: [PATCH 79/84] Fixed: a device failing a write or a stop mid-playback escaping the playing thread unreported, or leaving the song holding the output --- docs/development/application/playback.md | 2 +- .../services/song_player/service.py | 20 +++- src/sampletones_core/audio/manager.py | 61 +++++++----- src/sampletones_core/audio/stream.py | 35 +++++++ .../services/song_player/test_song_player.py | 42 +++++++- .../sampletones_core/audio/test_manager.py | 95 +++++++++++++++++++ 6 files changed, 224 insertions(+), 31 deletions(-) create mode 100644 src/sampletones_core/audio/stream.py diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md index 98c430087..ebe495daa 100644 --- a/docs/development/application/playback.md +++ b/docs/development/application/playback.md @@ -34,7 +34,7 @@ A report comes from the thread writing the audio, and the mark is a widget, so e Priority ranks the two kinds and settles every contest for the device. Starting intentional playback preempts a sounding preview, and a preview requested while intentional playback holds the device is declined. -**A sound starts only on a device in force.** Every request, of either kind, asks the device manager for the output before a thread or a stream opens, so a machine offering no output device refuses the request at once with `NoOutputDeviceError`. One presenter, `PlaybackFailurePresenter` (`coordinators/playback/failures.py`), tells the reader what stopped any sound: a missing output reads as a plain notice, and every other failure as an error. A failure on the thread playing the sound, such as a stream the device refused, reads the same whichever source played it, and is told once. +**A sound starts only on a device in force.** Every request, of either kind, asks the device manager for the output before a thread or a stream opens, so a machine offering no output device refuses the request at once with `NoOutputDeviceError`. One presenter, `PlaybackFailurePresenter` (`coordinators/playback/failures.py`), tells the reader what stopped any sound: a missing output reads as a plain notice, and every other failure as an error. A failure on the thread playing the sound, such as a stream the device refused or a write it failed, reads the same whichever source played it, and is told once. ## Engagement diff --git a/src/sampletones_application/services/song_player/service.py b/src/sampletones_application/services/song_player/service.py index 3e6b9c78a..6feb729a8 100644 --- a/src/sampletones_application/services/song_player/service.py +++ b/src/sampletones_application/services/song_player/service.py @@ -20,6 +20,7 @@ ) from sampletones_application.services.synthesis.protocol import RowSynthesizerProtocol from sampletones_core.audio import AudioDeviceManager, clip_audio_inplace +from sampletones_core.audio.stream import write_to_stream from sampletones_core.constants.audio import DEFAULT_BUFFER_SIZE from sampletones_core.project.song_position import SongPosition from sampletones_shared.constants.audio import UNITY_GAIN @@ -215,7 +216,8 @@ def _write_loop(self) -> None: """Drains the song to the device, hands the stream back, and then reports how the song ended. This is the writer thread's whole task, so a stream the device refuses ends here as the song's - error, reported once with the output free. + error, and so do a write and a hand-back the device fails. Each is reported once, with the + output free. """ try: stream = self._open_stream() @@ -225,17 +227,27 @@ def _write_loop(self) -> None: ended = True try: - ended = self._drain_to_stream(stream) + ended = self._play_through(stream) except Exception as exception: # pylint: disable=broad-exception-caught logger.error_with_traceback(exception, f"{self.class_name}: playback error") self._playback_error = exception finally: - self._audio_device_manager.close_output_stream(stream) self._holding_output.clear() if ended: self._emit_terminal() + def _play_through(self, stream: pyaudio.Stream) -> bool: + """Drains the song to ``stream``, and then hands the stream back to the device however the drain ended. + + Returns: + True once the song reached its end, False when a stop ended the drain. + """ + try: + return self._drain_to_stream(stream) + finally: + self._audio_device_manager.close_output_stream(stream) + def _open_stream(self) -> pyaudio.Stream: sample_rate = self._audio_device_manager.sample_rate stream = self._audio_device_manager.open_output_stream( @@ -300,7 +312,7 @@ def _write_chunk(self, stream: pyaudio.Stream, chunk: np.ndarray) -> bool: if self._stop_event.is_set(): return False - stream.write(chunk[offset : offset + self._write_block_frames].tobytes()) + write_to_stream(stream, chunk[offset : offset + self._write_block_frames].tobytes()) return True diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index 7184cfecb..bce88e241 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -23,6 +23,7 @@ from .device import AudioDevice, CurrentDevice from .io import load_audio +from .stream import close_stream, write_to_stream from .validation import validate_buffer_size, validate_sample_rate CHANNELS = 1 @@ -710,7 +711,7 @@ def _playback_loop( self._position += chunk_size current_position = self._position - stream.write(chunk.tobytes()) + write_to_stream(stream, chunk.tobytes()) if update and self._position_callback is not None: self.call(self._position_callback, current_position) @@ -726,8 +727,9 @@ def _playback_worker( Playback thread worker function. Opens an audio stream, runs the playback loop, and ensures cleanup. A stream the device - refuses to open leaves the playback idle, as a finished one does, before the error callback - reports it, so whoever follows the playback reads it stopped. + refuses to open, a write it fails and a stream it fails to stop each leave the playback + idle, as a finished one does, before the error callback reports the failure, so whoever + follows the playback reads it stopped. A stop waits a while for the worker and then lets it go, so a device slow to open can bring the worker back once its playback was stopped, or after a newer play has begun. The worker @@ -740,43 +742,51 @@ def _playback_worker( generation: The playback this worker plays, stamped by :meth:`play`. """ logger.debug(f"Starting playback: device_index={output.device_index}, sample_rate={output.sample_rate}") + try: + self._play_generation(output=output, update=update, generation=generation) + except PlaybackError as playback_error: + if self._is_current(generation): + self.call(self.on_playback_error, playback_error) + + def _play_generation( + self, + *, + output: CurrentDevice, + update: bool, + generation: int, + ) -> None: + """Plays ``generation``'s audio on a stream of its own, and leaves the playback idle however that ends. + + Raises: + PlaybackError: If the device refuses the stream, fails a write, or fails to stop the stream. + """ try: stream = self._open_stream( device_index=output.device_index, sample_rate=output.sample_rate, frames_per_buffer=pyaudio.paFramesPerBufferUnspecified, ) - except PlaybackError as playback_error: - if self._reset_generation(generation, update=update): - self.call(self.on_playback_error, playback_error) - return - - try: - if self._is_current(generation): - self._playback_loop(stream, update, generation) + try: + if self._is_current(generation): + self._playback_loop(stream, update, generation) + finally: + close_stream(stream) finally: - stream.stop_stream() - stream.close() self._reset_generation(generation, update=update) def _is_current(self, generation: int) -> bool: with self._lock: return generation == self._generation - def _reset_generation(self, generation: int, *, update: bool) -> bool: - """Resets playback state to idle while ``generation`` is the playback in force. - - Returns: - bool: Whether ``generation`` was in force, and the state was reset. - """ + def _reset_generation(self, generation: int, *, update: bool) -> None: + """Resets playback state to idle while ``generation`` is the playback in force.""" with self._lock: if generation != self._generation: - return False + return self._clear_playback() self._report_the_start(update=update) - return True def _reset(self, *, update: bool = True) -> None: """ @@ -966,13 +976,16 @@ def close_output_stream(self, stream: pyaudio.Stream) -> None: """Take a handed-out stream back and close it. Called by the owner from the thread that wrote to the stream, once that writing has - finished. Returning the stream is what tells the manager the backend is free again. + finished. Returning the stream is what tells the manager the backend is free again, so + the manager takes it back before it winds the stream down. + + Raises: + PlaybackError: If the device fails to stop or to close the stream. """ with self._lock: self._stream_owners.pop(stream, None) - stream.stop_stream() - stream.close() + close_stream(stream) def terminate(self) -> None: """ diff --git a/src/sampletones_core/audio/stream.py b/src/sampletones_core/audio/stream.py new file mode 100644 index 000000000..5eb4a9088 --- /dev/null +++ b/src/sampletones_core/audio/stream.py @@ -0,0 +1,35 @@ +import pyaudio + +from sampletones_shared.exceptions import PlaybackError + + +def write_to_stream(stream: pyaudio.Stream, data: bytes) -> None: + """Hands one block of audio to the device, naming a failed write the way every playback reports it. + + The manager's own playback and a source streaming from a thread of its own both write here, so a + reader told of a failed write reads the same words whichever source played. + + Raises: + PlaybackError: If the device fails the write. + """ + try: + stream.write(data) + except OSError as exception: + raise PlaybackError(f"Failed to write to audio stream: {exception}") from exception + + +def close_stream(stream: pyaudio.Stream) -> None: + """Stops a stream and closes it, naming a failure the way every playback reports it. + + The stream is closed even where the device fails to stop it, so its handle goes either way. + + Raises: + PlaybackError: If the device fails to stop or to close the stream. + """ + try: + try: + stream.stop_stream() + finally: + stream.close() + except OSError as exception: + raise PlaybackError(f"Failed to close audio stream: {exception}") from exception diff --git a/tests/unit/sampletones_application/services/song_player/test_song_player.py b/tests/unit/sampletones_application/services/song_player/test_song_player.py index c6d63e010..6e30b398b 100644 --- a/tests/unit/sampletones_application/services/song_player/test_song_player.py +++ b/tests/unit/sampletones_application/services/song_player/test_song_player.py @@ -139,6 +139,13 @@ def _make_streaming_service( ) +def _assert_names_the_failed_write(result: SongPlayerResult, error: OSError) -> None: + """Holds the song's end to the playback error a failed write names, the words every playback reports it in.""" + assert isinstance(result, SongPlaybackError) + assert isinstance(result.error, PlaybackError) + assert result.error.__cause__ is error + + def _wedged_thread(gate: threading.Event) -> threading.Thread: """A started worker that stays alive until ``gate`` is set.""" thread = threading.Thread( @@ -633,7 +640,9 @@ def test_a_failing_write_reports_it_with_the_output_free(self) -> None: service.start() assert reported.wait(timeout=WAIT_TIMEOUT) - assert ends == [(SongPlaybackError(error=error), False)] + [(result, alive)] = ends + _assert_names_the_failed_write(result, error) + assert alive is False def test_a_stream_the_device_refuses_reports_the_error_with_the_output_free(self) -> None: """The refusal reaches the listener once, as the error the device manager raised.""" @@ -650,6 +659,34 @@ def test_a_stream_the_device_refuses_reports_the_error_with_the_output_free(self service._write_thread.join(timeout=WAIT_TIMEOUT) assert ends == [(SongPlaybackError(error=refusal), False)] + def test_a_close_the_device_fails_reports_it_with_the_output_free(self) -> None: + failure = PlaybackError("Failed to close the audio stream: the device went away") + audio_device_manager = _make_device_manager(_FakeStream()) + audio_device_manager.close_output_stream.side_effect = failure + service = _make_streaming_service(audio_device_manager) + ends, reported = self.record_ends(service) + + service.start() + + assert reported.wait(timeout=WAIT_TIMEOUT) + assert ends == [(SongPlaybackError(error=failure), False)] + + def test_a_close_the_device_failed_leaves_the_song_free_to_start_again(self) -> None: + audio_device_manager = _make_device_manager(_FakeStream()) + audio_device_manager.close_output_stream.side_effect = PlaybackError("Failed to close the audio stream") + service = _make_streaming_service(audio_device_manager) + _, reported = self.record_ends(service) + service.start() + assert reported.wait(timeout=WAIT_TIMEOUT) + assert service._write_thread is not None + service._write_thread.join(timeout=WAIT_TIMEOUT) + + audio_device_manager.close_output_stream.side_effect = _close_stream + service.start() + service.stop() + + assert audio_device_manager.open_output_stream.call_count == 2 + def test_a_stop_reports_no_end(self) -> None: gate = threading.Event() stream = _FakeStream(gate=gate) @@ -707,7 +744,8 @@ def test_a_failing_write_reports_a_playback_error(self) -> None: service._write_loop() - assert received == [SongPlaybackError(error=error)] + [result] = received + _assert_names_the_failed_write(result, error) def test_a_failing_write_still_closes_the_stream(self) -> None: stream = _FakeStream(error=OSError("device disappeared")) diff --git a/tests/unit/sampletones_core/audio/test_manager.py b/tests/unit/sampletones_core/audio/test_manager.py index 0a29faef5..680802337 100644 --- a/tests/unit/sampletones_core/audio/test_manager.py +++ b/tests/unit/sampletones_core/audio/test_manager.py @@ -9,6 +9,7 @@ from sampletones_core.audio.device import AudioDevice, CurrentDevice from sampletones_core.audio.manager import AudioDeviceManager +from sampletones_core.audio.stream import write_to_stream from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE, START_OF_AUDIO, SampleRate from sampletones_shared.exceptions import NoOutputDeviceError, PlaybackError from tests.suite.base import BaseTestSuite @@ -324,6 +325,100 @@ def test_the_newer_worker_refused_reports_it( assert not manager.is_playing() +class TestADeviceFailingMidPlayback: + """A write or a stop the device fails leaves the playback idle, and then reports the failure once. + + The failure reaches the reader the way a refused stream does, and only while the playback that met it + is the one in force. + """ + + LENGTH: Final[int] = 8 + + @pytest.fixture(name="playing") + def playing_fixture(self) -> Tuple[AudioDeviceManager, Dict[str, Any], MagicMock]: + """A manager playing a buffer, the arguments its worker was started with, and the stream it opens.""" + manager = _manager() + manager._buffer_size = self.LENGTH + manager._position_callback = None + manager._playback_thread = None + stream = MagicMock() + manager._pyaudio.open.return_value = stream + with patch("sampletones_core.audio.manager.threading.Thread") as thread: + manager.play(np.zeros(self.LENGTH, dtype=np.float32)) + + return manager, thread.call_args.kwargs["kwargs"], stream + + @staticmethod + def record_reports(manager: AudioDeviceManager) -> List[Tuple[PlaybackError, bool]]: + """Notes each failure reported with whether the playback read playing then.""" + reports: List[Tuple[PlaybackError, bool]] = [] + manager.on_playback_error = lambda error: reports.append((error, manager.is_playing())) + return reports + + def test_a_failed_write_is_reported_once_with_the_playback_stopped( + self, + playing: Tuple[AudioDeviceManager, Dict[str, Any], MagicMock], + ) -> None: + manager, worker, stream = playing + stream.write.side_effect = OSError("the device went away") + reports = self.record_reports(manager) + + manager._playback_worker(**worker) + + assert [is_playing for _, is_playing in reports] == [False] + assert "the device went away" in str(reports[0][0]) + stream.close.assert_called_once_with() + + def test_a_failed_stop_still_closes_the_stream_and_leaves_the_playback_idle( + self, + playing: Tuple[AudioDeviceManager, Dict[str, Any], MagicMock], + ) -> None: + manager, worker, stream = playing + stream.stop_stream.side_effect = OSError("the device went away") + reports = self.record_reports(manager) + + manager._playback_worker(**worker) + + assert [is_playing for _, is_playing in reports] == [False] + stream.close.assert_called_once_with() + assert not manager.is_playing() + + def test_a_write_failing_after_a_newer_play_reports_nothing( + self, + playing: Tuple[AudioDeviceManager, Dict[str, Any], MagicMock], + ) -> None: + """The newer playback stands, and the failure belongs to the playback it replaced.""" + manager, worker, stream = playing + + def replaced_then_failing(_data: bytes) -> None: + with patch("sampletones_core.audio.manager.threading.Thread"): + manager.play(np.ones(self.LENGTH, dtype=np.float32)) + raise OSError("the device went away") + + stream.write.side_effect = replaced_then_failing + reports = self.record_reports(manager) + + manager._playback_worker(**worker) + + assert reports == [] + assert manager.is_playing() + + def test_a_failed_write_reads_as_the_failed_write_of_a_handed_out_stream( + self, + playing: Tuple[AudioDeviceManager, Dict[str, Any], MagicMock], + ) -> None: + """Both kinds of playback name the failure alike, so the reader reads the same words.""" + manager, worker, stream = playing + stream.write.side_effect = OSError("the device went away") + reports = self.record_reports(manager) + manager._playback_worker(**worker) + + with pytest.raises(PlaybackError) as raised: + write_to_stream(stream, np.zeros(self.LENGTH, dtype=np.float32).tobytes()) + + assert [str(error) for error, _ in reports] == [str(raised.value)] + + class TestAPlaybackAStopLetGo: """A worker a stop let go of plays for nobody, so what its device does later reaches nothing. From 7910571c31ed5ace6cfbd0d9bbcb480dda5103ee Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:44:25 +0200 Subject: [PATCH 80/84] Fixed: the audio backend torn down under a playback worker a stop let go of --- docs/development/application/playback.md | 2 + src/sampletones_core/audio/manager.py | 69 +++++++++++++--- .../sampletones_core/audio/test_manager.py | 82 +++++++++++++++++++ 3 files changed, 141 insertions(+), 12 deletions(-) diff --git a/docs/development/application/playback.md b/docs/development/application/playback.md index ebe495daa..894431c6c 100644 --- a/docs/development/application/playback.md +++ b/docs/development/application/playback.md @@ -130,6 +130,8 @@ The device is torn down once every source holding a stream has released it. A so The device holds a release per stream it handed out and invokes it whenever it needs the output free: as the backend is torn down, and on a device change, where the release stops the song so the new device opens cleanly. A stream that outlives its release leaves the running backend in place. The manager reports the failure and keeps the instance, since the source still writes to memory that terminating would reclaim. +The manager's own playback is held to the same rule. A stop waits a while for its worker and then lets it go, since a device can be slow to open a stream. The manager keeps a worker it let go of, and a teardown waits for it, so the backend is never terminated under an open in progress. A worker that outlives that wait leaves the backend running, as a stream that outlives its release does. + ## Who governs what | Concern | Owner | diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index bce88e241..ccd853e5d 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -3,7 +3,7 @@ import sys import threading from pathlib import Path -from typing import Any, Callable, Dict, Generator, List, Optional, cast +from typing import Any, Callable, Dict, Final, Generator, List, Optional, cast import numpy as np import pyaudio @@ -28,6 +28,8 @@ CHANNELS = 1 FORMAT = pyaudio.paFloat32 +STOP_JOIN_SECONDS: Final[float] = 1.0 +LET_GO_JOIN_SECONDS: Final[float] = 2.0 OnPlaybackErrorCallback = Callable[[PlaybackError], None] @@ -95,6 +97,7 @@ def __init__(self) -> None: self._generation: int = 0 self._playback_thread: Optional[threading.Thread] = None + self._let_go_workers: List[threading.Thread] = [] self._stream_owners: Dict[pyaudio.Stream, VoidCallback] = {} self._lock: threading.Lock = threading.Lock() self._resume_event: threading.Event = threading.Event() @@ -115,11 +118,13 @@ def reinitialize(self) -> None: Creates a new PyAudio instance if none exists, or terminates the existing instance and creates a new one. Active playback is stopped and every handed-out output stream is released first, since terminating closes any stream still open - while its owner writes to it. + while its owner writes to it. A worker a stop let go of is waited for too, since it + may still be opening its stream. Raises: PlaybackError: If an output stream is still held after its owner was asked to - release it, which leaves the running instance in place. + release it, or a worker a stop let go of outlives the wait, which leaves the + running instance in place. """ with _capture_stderr_to_logger(): if self._pyaudio is None: @@ -131,6 +136,9 @@ def reinitialize(self) -> None: if not self._release_output_streams(): raise PlaybackError("An output stream is still held; the audio backend stays as it is") + if not self._join_let_go_workers(): + raise PlaybackError("A playback worker still reaches into the audio backend; it stays as it is") + self._pyaudio.terminate() self._pyaudio = pyaudio.PyAudio() logger.debug("AudioDeviceManager reinitialized") @@ -842,10 +850,11 @@ def stop(self) -> None: """ Stop playback and reset state. - Signals the playback thread to stop, waits for it to terminate (up to 1 second), - and resets all playback state. The stopped playback's generation ends here, so a worker - the wait lets go of acts for no playback: a refusal it meets later reaches nobody, and the - state stays as the stop left it. + Signals the playback thread to stop, waits for it to terminate (up to + ``STOP_JOIN_SECONDS``), and resets all playback state. The stopped playback's generation + ends here, so a worker the wait lets go of acts for no playback: a refusal it meets later + reaches nobody, and the state stays as the stop left it. The manager keeps that worker, so + a teardown of the backend waits for it. """ with self._lock: self._stop = True @@ -853,11 +862,41 @@ def stop(self) -> None: self._resume_event.set() if self._playback_thread is not None: - self._playback_thread.join(timeout=1.0) + self._let_go(self._playback_thread) self._playback_thread = None self._reset() + def _let_go(self, worker: threading.Thread) -> None: + """Waits a while for a stopped worker, and keeps one that outlives the wait for a teardown to join.""" + worker.join(timeout=STOP_JOIN_SECONDS) + with self._lock: + self._let_go_workers = [kept for kept in self._let_go_workers if kept.is_alive()] + if worker.is_alive(): + self._let_go_workers.append(worker) + + def _join_let_go_workers(self) -> bool: + """Waits a while for each worker a stop let go of, and reports whether every one has ended. + + Such a worker may still be opening its stream on the backend, so the backend is torn down only + once it has ended. A worker that outlives ``LET_GO_JOIN_SECONDS`` is logged and kept, as a + stream that outlives its release is. + """ + with self._lock: + workers, self._let_go_workers = self._let_go_workers, [] + + outliving: List[threading.Thread] = [] + for worker in workers: + worker.join(timeout=LET_GO_JOIN_SECONDS) + if worker.is_alive(): + logger.error(f"AudioDeviceManager: {worker.name} outlived the teardown deadline") + outliving.append(worker) + + with self._lock: + self._let_go_workers.extend(outliving) + + return not outliving + def is_playing(self) -> bool: """ Check if audio is currently playing. @@ -991,10 +1030,12 @@ def terminate(self) -> None: """ Clean up and terminate the audio device manager. - Stops any active playback, releases every handed-out output stream, and terminates - the PyAudio instance. A stream that survives its release leaves the instance running, - since terminating closes any open stream and the owning thread would go on writing to - freed memory. Should be called once you are finished with the manager. + Stops any active playback, releases every handed-out output stream, waits for the + workers a stop let go of, and terminates the PyAudio instance. A stream that survives its + release leaves the instance running, since terminating closes any open stream and the + owning thread would go on writing to freed memory. A worker that outlives the wait leaves it + running too, since it may still be opening its stream. Should be called once you are + finished with the manager. """ if self._pyaudio is None: return @@ -1004,6 +1045,10 @@ def terminate(self) -> None: logger.error("AudioDeviceManager: an output stream is still held; PyAudio left running") return + if not self._join_let_go_workers(): + logger.error("AudioDeviceManager: a playback worker is still running; PyAudio left running") + return + self._pyaudio.terminate() self._pyaudio = None diff --git a/tests/unit/sampletones_core/audio/test_manager.py b/tests/unit/sampletones_core/audio/test_manager.py index 680802337..074bcae15 100644 --- a/tests/unit/sampletones_core/audio/test_manager.py +++ b/tests/unit/sampletones_core/audio/test_manager.py @@ -18,6 +18,10 @@ _LOW = 0 _HIGH = 1 _RELEASE_TIMEOUT: Final[float] = 5.0 +_OPEN_DELAY: Final[float] = 0.2 +_SHORT_JOIN: Final[float] = 0.05 +_STOP_JOIN_TARGET: Final[str] = "sampletones_core.audio.manager.STOP_JOIN_SECONDS" +_LET_GO_JOIN_TARGET: Final[str] = "sampletones_core.audio.manager.LET_GO_JOIN_SECONDS" _BACKEND: Final[str] = "sampletones_core.audio.manager.pyaudio.PyAudio" _SPEAKERS_INDEX: Final[int] = 0 _SPEAKERS_NAME: Final[str] = "Speakers" @@ -57,6 +61,7 @@ def _manager() -> AudioDeviceManager: manager._playing = False manager._active_priority = 0 manager._generation = 0 + manager._let_go_workers = [] manager._stream_owners = {} manager.on_acquire_output = None manager.external_output_priority = None @@ -658,6 +663,83 @@ def test_a_release_may_hand_its_stream_back_from_the_writing_thread(self) -> Non assert manager._pyaudio is None +class TestTeardownAfterAStopLetAWorkerGo: + """Teardown waits for a worker a stop let go of, so the backend goes once nothing reaches into it. + + A device slow to open keeps the worker past the stop's wait, and terminating the backend under an + open in progress pulls it from under the worker. + """ + + LENGTH: Final[int] = 8 + + @pytest.fixture(name="slow_open") + def slow_open_fixture(self) -> Iterator[Tuple[AudioDeviceManager, threading.Event, List[str]]]: + """A manager whose playback a stop let go of while its stream opens, the open's gate, and what happened.""" + events: List[str] = [] + opening = threading.Event() + manager = _manager() + manager._buffer_size = self.LENGTH + manager._position_callback = None + manager._playback_thread = None + manager.on_playback_error = None + + def open_slowly(**_options: Any) -> MagicMock: + opening.wait(timeout=_RELEASE_TIMEOUT) + events.append("opened") + return MagicMock() + + manager._pyaudio.open.side_effect = open_slowly + manager._pyaudio.terminate.side_effect = lambda: events.append("terminate") + manager.play(np.zeros(self.LENGTH, dtype=np.float32)) + with patch(_STOP_JOIN_TARGET, _SHORT_JOIN): + manager.stop() + + yield manager, opening, events + opening.set() + + def test_terminate_waits_for_the_worker_to_finish_its_open( + self, + slow_open: Tuple[AudioDeviceManager, threading.Event, List[str]], + ) -> None: + manager, opening, events = slow_open + releaser = threading.Timer(_OPEN_DELAY, opening.set) + releaser.start() + try: + manager.terminate() + finally: + releaser.cancel() + opening.set() + + assert events == ["opened", "terminate"] + assert manager._pyaudio is None + + def test_terminate_keeps_the_backend_while_the_worker_outlives_the_wait( + self, + slow_open: Tuple[AudioDeviceManager, threading.Event, List[str]], + ) -> None: + manager, _, events = slow_open + instance = manager._pyaudio + + with patch(_LET_GO_JOIN_TARGET, _SHORT_JOIN): + manager.terminate() + + assert events == [] + assert manager._pyaudio is instance + + def test_reinitialize_refuses_while_the_worker_outlives_the_wait( + self, + slow_open: Tuple[AudioDeviceManager, threading.Event, List[str]], + ) -> None: + manager, _, events = slow_open + instance = manager._pyaudio + + with patch(_LET_GO_JOIN_TARGET, _SHORT_JOIN), pytest.raises(PlaybackError): + manager.reinitialize() + + assert events == [] + assert manager._pyaudio is instance + + @pytest.fixture(name="backend") def backend_fixture() -> Iterator[MagicMock]: """The audio backend of a machine offering one pair of speakers as its default output.""" From f9f7c30dac4f11cd21e60c7540422ee38e9035e2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:46:01 +0200 Subject: [PATCH 81/84] Fixed: a playback refused at its start opening with other words than one refused on the playing thread --- .../coordinators/playback/failures.py | 6 +++--- .../coordinators/playback/guard.py | 7 +++---- .../coordinators/tabs/instructions.py | 1 - .../coordinators/tabs/reconstruction.py | 1 - .../coordinators/tabs/sequencer/coordinator.py | 1 - src/sampletones_config/lang/en.yaml | 1 - .../coordinators/playback/test_guard.py | 16 ++++++++++------ .../coordinators/tabs/test_sequencer.py | 14 ++++++-------- 8 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/sampletones_application/coordinators/playback/failures.py b/src/sampletones_application/coordinators/playback/failures.py index 3f5c57c78..e559f2bc4 100644 --- a/src/sampletones_application/coordinators/playback/failures.py +++ b/src/sampletones_application/coordinators/playback/failures.py @@ -49,9 +49,9 @@ def present( self._dialogs.show_error(exception, message) def present_playing_failure(self, exception: Exception) -> None: - """Shows the reader what stopped a playback on the thread playing it, such as a stream the device refused. + """Shows the reader what stopped a playback they asked for, such as a stream the device refused. - Every source reports such a failure with the same opening line, so one refusal reads alike on - every tab. + Every source reports such a failure with the same opening line, whether its start failed at + once or on the thread playing it, so one refusal reads alike on every tab. """ self.present(exception, message=self._language_manager["global.dialog.message.audio_playback_error"]) diff --git a/src/sampletones_application/coordinators/playback/guard.py b/src/sampletones_application/coordinators/playback/guard.py index 05e34b243..9f86d8a48 100644 --- a/src/sampletones_application/coordinators/playback/guard.py +++ b/src/sampletones_application/coordinators/playback/guard.py @@ -10,7 +10,8 @@ class GuardedPlayer: Panels only fire intent hooks, so this wrapper is the coordinator-layer recovery boundary for the transport commands that can raise - ``PlaybackError``; queries pass straight through. + ``PlaybackError``; queries pass straight through. A start that fails at once + reads the way a failure on the playing thread does. """ def __init__( @@ -18,11 +19,9 @@ def __init__( player: AudioPlayerProtocol, *, failures: PlaybackFailurePresenter, - error_message: str, ) -> None: self._player = player self._failures = failures - self._error_message = error_message def play(self) -> None: self.run_guarded(self._player.play) @@ -54,4 +53,4 @@ def run_guarded(self, command: VoidCallback) -> None: try: command() except PlaybackError as exception: - self._failures.present(exception, message=self._error_message) + self._failures.present_playing_failure(exception) diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index 43fc2601e..4ba5ce47a 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -180,7 +180,6 @@ def __init__( self._guarded_player = GuardedPlayer( self._instruction_player_logic, failures=playback_failures, - error_message=language_manager["global.player.message.audio_playback_error"], ) self._waveform_panel = GUIInstructionWaveformPanel( initial_collapsed=session_manager.is_card_collapsed(TAG_INSTRUCTIONS_INSTRUCTION_PANEL_WAVEFORM), diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 1637e6cc2..b427fadee 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -231,7 +231,6 @@ def __init__( self._guarded_player = GuardedPlayer( self._reconstruction_player_logic, failures=playback_failures, - error_message=language_manager["global.player.message.audio_playback_error"], ) self._reconstruction_audio_panel: GUIReconstructionAudioPanel = GUIReconstructionAudioPanel( path_colors=layout.path_colors, diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 7b6cf5221..ebee62eb2 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -197,7 +197,6 @@ def __init__( self._guarded_player = GuardedPlayer( self._song_player_logic, failures=playback_failures, - error_message=language_manager["global.player.message.audio_playback_error"], ) self._sequencer_tracker_panel: GUISequencerTrackerPanel = GUISequencerTrackerPanel( self._sequencer_tracker_logic.settings, diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 541ed17e7..430944e85 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -313,7 +313,6 @@ global.player.label.play: "Play" global.player.label.pause: "Pause" global.player.label.resume: "Resume" global.player.label.stop: "Stop" -global.player.message.audio_playback_error: "Audio playback error." # ============================================================================= # Global — Graph diff --git a/tests/unit/sampletones_application/coordinators/playback/test_guard.py b/tests/unit/sampletones_application/coordinators/playback/test_guard.py index 957550967..f5873fe87 100644 --- a/tests/unit/sampletones_application/coordinators/playback/test_guard.py +++ b/tests/unit/sampletones_application/coordinators/playback/test_guard.py @@ -21,12 +21,16 @@ def failures() -> MagicMock: @pytest.fixture def guarded_player(player_logic: MagicMock, failures: MagicMock) -> GuardedPlayer: - return GuardedPlayer(player_logic, failures=failures, error_message="playback failed") + return GuardedPlayer(player_logic, failures=failures) class TestGuardedCommands: """The transport commands that can raise ``PlaybackError`` hand it to the presenter instead of - propagating, so a panel hook or the playback router can invoke them bare.""" + propagating, so a panel hook or the playback router can invoke them bare. + + A start that fails at once reads the way a failure on the playing thread does, so one refusal reads + alike however soon the device refused. + """ @pytest.mark.parametrize("command", GUARDED_COMMANDS) def test_delegates_to_the_logic( @@ -39,7 +43,7 @@ def test_delegates_to_the_logic( getattr(guarded_player, command)() getattr(player_logic, command).assert_called_once_with() - failures.present.assert_not_called() + failures.present_playing_failure.assert_not_called() @pytest.mark.parametrize("command", GUARDED_COMMANDS) @pytest.mark.parametrize( @@ -59,7 +63,7 @@ def test_a_playback_error_reaches_the_presenter( getattr(guarded_player, command)() - failures.present.assert_called_once_with(exception, message="playback failed") + failures.present_playing_failure.assert_called_once_with(exception) class TestAGuardedRun: @@ -71,7 +75,7 @@ def test_the_command_runs(self, guarded_player: GuardedPlayer, failures: MagicMo guarded_player.run_guarded(lambda: ran.append(400)) assert ran == [400] - failures.present.assert_not_called() + failures.present_playing_failure.assert_not_called() def test_a_playback_error_reaches_the_presenter(self, guarded_player: GuardedPlayer, failures: MagicMock) -> None: exception = PlaybackError("device unavailable") @@ -81,4 +85,4 @@ def failing() -> None: guarded_player.run_guarded(failing) - failures.present.assert_called_once_with(exception, message="playback failed") + failures.present_playing_failure.assert_called_once_with(exception) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 093ccf018..9ea02b8d8 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -1792,7 +1792,6 @@ def exposure_coordinator() -> SequencerTabCoordinator: instance._guarded_player = GuardedPlayer( instance._song_player_logic, failures=MagicMock(), - error_message="playback failed", ) return instance @@ -1828,7 +1827,6 @@ def refusing_coordinator_fixture(failures: MagicMock) -> SequencerTabCoordinator instance._guarded_player = GuardedPlayer( instance._song_player_logic, failures=failures, - error_message="playback failed", ) return instance @@ -1843,8 +1841,8 @@ def test_play_from_this_frame_reaches_the_presenter( ) -> None: refusing_coordinator.play_from_current_frame() - failures.present.assert_called_once() - assert isinstance(failures.present.call_args.args[0], NoOutputDeviceError) + failures.present_playing_failure.assert_called_once() + assert isinstance(failures.present_playing_failure.call_args.args[0], NoOutputDeviceError) def test_play_from_a_row_reaches_the_presenter( self, @@ -1853,8 +1851,8 @@ def test_play_from_a_row_reaches_the_presenter( ) -> None: refusing_coordinator._on_tracker_play_from_row(4) - failures.present.assert_called_once() - assert isinstance(failures.present.call_args.args[0], NoOutputDeviceError) + failures.present_playing_failure.assert_called_once() + assert isinstance(failures.present_playing_failure.call_args.args[0], NoOutputDeviceError) def test_play_from_an_order_frame_reaches_the_presenter( self, @@ -1863,8 +1861,8 @@ def test_play_from_an_order_frame_reaches_the_presenter( ) -> None: refusing_coordinator._play_from_frame(1) - failures.present.assert_called_once() - assert isinstance(failures.present.call_args.args[0], NoOutputDeviceError) + failures.present_playing_failure.assert_called_once() + assert isinstance(failures.present_playing_failure.call_args.args[0], NoOutputDeviceError) PULSE1_CELL: Final[TrackerRegion] = TrackerRegion( From 9bd09bb45a870ddb1381a8a80f3f61e23329795b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:47:16 +0200 Subject: [PATCH 82/84] Fixed: the modal line's description saying a window asked for again keeps its place in line --- docs/development/application/dialogs.md | 4 ++-- src/sampletones_application/utils/gui/modal_queue.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/development/application/dialogs.md b/docs/development/application/dialogs.md index c4f872274..30c70a892 100644 --- a/docs/development/application/dialogs.md +++ b/docs/development/application/dialogs.md @@ -53,8 +53,8 @@ The screen therefore belongs to one conversation at a time. A conversation is a hands the screen to while it steps aside, and the ones its answers raise. A modal asked for from anywhere else, such as the report of a job that finished, waits in line. It opens once the conversation holding the screen has ended, a frame after its last window left, and the line opens in the order it was asked. A dialog -asked for again while it waits keeps its place with the newer request, and one hidden while it waits leaves -the line. +asked for again while it waits takes one place, at the back of the line, with the newer request. One hidden +while it waits leaves the line. **A question reads what it asks about once the screen is free for it.** A guard with something to ask takes a turn in the line, and reads its state again when the line reaches that turn. The conversation that diff --git a/src/sampletones_application/utils/gui/modal_queue.py b/src/sampletones_application/utils/gui/modal_queue.py index 01b1f3105..0c7a0da9b 100644 --- a/src/sampletones_application/utils/gui/modal_queue.py +++ b/src/sampletones_application/utils/gui/modal_queue.py @@ -69,8 +69,8 @@ class ModalQueue(metaclass=NonInstantiableMeta): the screen to while it steps aside, and the ones its answers raise. A modal asked for from anywhere else, such as the report of a job that finished, takes its place in line and opens once the conversation holding the screen has ended, a frame after its last window left. A window asked - for again while it waits keeps its place in line with the newer request. The line is one for the - process, as DearPyGui's context is. + for again while it waits takes one place, at the back of the line, with the newer request. The line + is one for the process, as DearPyGui's context is. A guard with something to ask takes a turn in the line, so it reads what it asks about once the screen is free for its question. The conversation that held the screen has settled what it From 737fd483c5c12d10d62ca6782b7a6a3ac74e96f9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 17:47:31 +0200 Subject: [PATCH 83/84] Fixed: the screen tier's stale homes sweep reading every worker gone where the process folder is missing --- docs/development/application/screens.md | 4 +- .../integration/tooling/test_screen_homes.py | 69 +++++++++++++++++++ tests/suite/screens/homes.py | 7 +- tests/suite/screens/plugin/hooks.py | 2 +- 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 tests/integration/tooling/test_screen_homes.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index be8c6eb99..372a3fc4f 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -303,8 +303,8 @@ SAMPLETONES_SCREENS_DISPLAY=xephyr uv run python -m pytest tests/screens --no-co A scenario's home is scratch: it is built from the scenario's world in a temporary folder made for each worker, whose path holds no hidden folder, so the application's browsers reach it from a worktree under `.worktrees/` too, and it goes once the scenario ends. A temporary folder inside a hidden folder is -refused, and `TMPDIR` points the run at another. The worker's folder is named after its process, and a -run's first scenario removes the folders whose process has gone, such as a crashed worker's, and leaves +refused, and `TMPDIR` points the run at another. The worker's folder is named after its process, and each +worker's first scenario removes the folders whose process has gone, such as a crashed worker's, and leaves another live run's alone. A folder a scenario left locked opens again before its home is copied and removed. diff --git a/tests/integration/tooling/test_screen_homes.py b/tests/integration/tooling/test_screen_homes.py new file mode 100644 index 000000000..15b21d5d4 --- /dev/null +++ b/tests/integration/tooling/test_screen_homes.py @@ -0,0 +1,69 @@ +import os +import tempfile +from pathlib import Path +from typing import Final + +import pytest + +from tests.suite.screens import homes +from tests.suite.screens.paths import HOMES_PREFIX, PROCESSES_DIRECTORY + +GONE_PROCESS: Final[int] = 2**22 + 1 +MISSING_FOLDER: Final[str] = "no-processes" +TAIL: Final[str] = "tail" + +requires_the_process_folder = pytest.mark.skipif( + not PROCESSES_DIRECTORY.is_dir(), + reason="the sweep reads which processes run from the process folder Linux keeps", +) + + +@pytest.fixture(name="temporary") +def temporary_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """The temporary folder the worker's homes are made in, standing apart from the machine's own.""" + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + return tmp_path + + +def left_homes(parent: Path, process: int) -> Path: + """A homes folder a worker of ``process`` made and left behind.""" + folder = parent / f"{HOMES_PREFIX}{process}{homes.PROCESS_SEPARATOR}{TAIL}" + folder.mkdir() + return folder + + +class TestTheStaleHomesSweep: + """A worker's first scenario lets go of the homes a gone worker left, and keeps a running worker's. + + The process id beyond the largest one Linux hands out names a worker that has gone. + """ + + @requires_the_process_folder + def test_the_homes_of_a_gone_worker_go(self, temporary: Path) -> None: + stale = left_homes(temporary, GONE_PROCESS) + + homes.make_worker_homes() + + assert not stale.exists() + + @requires_the_process_folder + def test_the_homes_of_a_running_worker_stay(self, temporary: Path) -> None: + running = left_homes(temporary, os.getppid()) + + made = homes.make_worker_homes() + + assert running.exists() + assert made.exists() + + def test_every_homes_folder_stays_where_the_process_folder_is_missing( + self, + temporary: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A machine with no process folder tells no worker gone, so the sweep leaves every folder as it is.""" + monkeypatch.setattr(homes, "PROCESSES_DIRECTORY", temporary / MISSING_FOLDER) + left = [left_homes(temporary, GONE_PROCESS), left_homes(temporary, os.getppid())] + + homes.make_worker_homes() + + assert all(folder.exists() for folder in left) diff --git a/tests/suite/screens/homes.py b/tests/suite/screens/homes.py index e64fe0144..292133cf2 100644 --- a/tests/suite/screens/homes.py +++ b/tests/suite/screens/homes.py @@ -70,8 +70,13 @@ def _let_stale_homes_go(parent: Path) -> None: """Removes the homes folders under ``parent`` whose worker process has gone, and keeps every other one. A worker removes its own folder as it ends, so a folder left behind belongs to a worker that crashed, - or to a run still going. A folder whose name carries no process is left alone. + or to a run still going. A folder whose name carries no process is left alone. Whether a process runs + is read from the process folder Linux keeps, as the tier runs on Linux, so a machine without that + folder keeps every homes folder. """ + if not PROCESSES_DIRECTORY.is_dir(): + return + for folder in parent.glob(f"{HOMES_PREFIX}*"): process = _owner_process(folder) if process is not None and not _is_running(process): diff --git a/tests/suite/screens/plugin/hooks.py b/tests/suite/screens/plugin/hooks.py index 608373cfb..ec4e96b46 100644 --- a/tests/suite/screens/plugin/hooks.py +++ b/tests/suite/screens/plugin/hooks.py @@ -71,7 +71,7 @@ def _worker_homes(config: pytest.Config) -> Path: """The temporary folder this worker's scenarios keep their homes in, made with the first of them. Each worker makes its own, named after its process, so runs from several checkouts at once keep - their homes apart, and the first scenario of a run lets go of what crashed workers left. + their homes apart, and each worker's first scenario lets go of what crashed workers left. """ homes = config.stash.get(HOMES_KEY, None) if homes is not None: From bc14d693e9167dde178de51ed76522f6c634d246 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 4 Oct 2026 21:06:17 +0200 Subject: [PATCH 84/84] Fixed: the screen tier's scroll into view measuring a row before a frame laid it out, which left the explorer unscrolled on slow runs --- docs/development/application/screens.md | 4 +- .../main/gathering/test_after_a_refresh.py | 34 ++++++++++++ .../screens/dearpygui/gestures/scrolling.py | 55 ++++++++++++++++++- 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 tests/screens/main/gathering/test_after_a_refresh.py diff --git a/docs/development/application/screens.md b/docs/development/application/screens.md index 372a3fc4f..922f3352c 100644 --- a/docs/development/application/screens.md +++ b/docs/development/application/screens.md @@ -127,7 +127,9 @@ since a dialog sized by its content settles its place over its first frames ([di 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. +wheel. The hand measures how far to scroll once a frame has laid the control out: a row found in the frame +that built it has no place yet, and a region learns how far it scrolls a frame after it draws what it +holds. 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/tests/screens/main/gathering/test_after_a_refresh.py b/tests/screens/main/gathering/test_after_a_refresh.py new file mode 100644 index 000000000..c3ab79e0b --- /dev/null +++ b/tests/screens/main/gathering/test_after_a_refresh.py @@ -0,0 +1,34 @@ +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.main import TAG_MAIN_EXPLORER_BUTTON_REFRESH +from tests.screens.main.gathering.steps import gathered +from tests.suite.screens.screen import Screen +from tests.suite.screens.steps.main import explorer_row, gather, home_path +from tests.suite.screens.vocabulary.recordings import KICK + + +class TestGatheringARowRefreshBuiltAnew: + """A recording Ctrl-clicked the moment Refresh brings its row back joins the list. + + Refresh takes the browser's rows down and builds them anew over a few frames, so the row of the + recording comes back as a new row far below the top of a long browser. The scenario presses + Refresh, waits for the row to come back and Ctrl-clicks it at once, expecting the recording in the + list. + """ + + def test_the_rebuilt_row_gathers(self, screen: Screen) -> None: + explorer = screen.explorer + kick = home_path(KICK) + + def refresh(screen: Screen) -> None: + before = explorer_row(screen, kick) + + screen.hand.click(compose_tag(TAG_MAIN_EXPLORER_BUTTON_REFRESH)) + + screen.expect(lambda: explorer.file_row(kick), before.__ne__, description="the row taken down") + + def ctrl_click_the_row_it_brought_back(screen: Screen) -> None: + gather(screen, kick) + + gathered(screen, kick) + + screen.scenario(refresh, ctrl_click_the_row_it_brought_back).run() diff --git a/tests/suite/screens/dearpygui/gestures/scrolling.py b/tests/suite/screens/dearpygui/gestures/scrolling.py index 0d5875dbc..35aee2747 100644 --- a/tests/suite/screens/dearpygui/gestures/scrolling.py +++ b/tests/suite/screens/dearpygui/gestures/scrolling.py @@ -1,9 +1,9 @@ from functools import partial -from typing import Final +from typing import Final, Optional, Tuple 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.constants import HOVER_FRAMES, REACH_TIMEOUT_SECONDS, 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 ( @@ -24,6 +24,8 @@ GRIP_DRAGS: Final[int] = 4 IMGUI_GRAB_MIN_SIZE: Final[float] = 12.0 +RegionLayout = Tuple[float, float] + class Scrolling(Pointer): """The wheel and the scrollbars of a hand: turning the wheel over a region and bringing an item into view. @@ -110,11 +112,37 @@ def scroll_into_view(self, item: Item) -> None: 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)): + regions = self._bridge.ask(lambda: enclosing_regions(item)) + self._await_layout(item, regions) + for region in regions: self._scroll_within(item, region) self._settle(SETTLE_FRAMES) + def _await_layout(self, item: Item, regions: Tuple[Item, ...]) -> None: + """Waits until a frame has laid ``item`` out in ``regions`` and the regions have measured what they hold. + + A row built in the frame it is found in has no place yet, and a region learns how far it can + scroll a frame after it draws what it holds, so a reading taken sooner finds nothing to scroll. + The layout has settled once where the item stands in each region's content and how far each + region can scroll read the same two frames running. Both are counted from the content's top, + so a region scrolling on its own, such as one following the playback, settles all the same. + + Raises: + UnreachableError: If the layout keeps changing until the wait runs out. + """ + deadline = self._bridge.deadline(REACH_TIMEOUT_SECONDS) + previous: Optional[Tuple[RegionLayout, ...]] = None + while True: + layout = self._bridge.ask(partial(_layout_in, item, regions)) + if layout is not None and layout == previous: + return + if deadline.passed(): + raise UnreachableError(f"{item!r} found no place in its regions: it stood at {previous}, then {layout}") + + previous = layout + self._bridge.frames(ONE_FRAME) + def _scroll_within(self, item: Item, region: Item) -> None: notch = 0.0 for _ in range(SCROLL_NOTCH_LIMIT): @@ -153,6 +181,27 @@ def _turn_wheel(self, notches: int) -> None: self._settle(WHEEL_FRAMES) +def _layout_in(item: Item, regions: Tuple[Item, ...]) -> Optional[Tuple[RegionLayout, ...]]: + """Where ``item`` stands in the content of each region, and how far each region can scroll. + + Reads ``None`` while the item or a region reports no box. Runs on the render thread. + """ + item_box = read_item(item).rect + if item_box is None: + return None + + layout = [] + for region in regions: + region_box = read_region_view(region) + if region_box is None: + return None + + scroll = read_scroll(region) + layout.append((item_box.y - region_box.y + scroll.position, scroll.maximum)) + + return tuple(layout) + + 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.