From abc3cf04399de265cde51988e15d5e886027aec0 Mon Sep 17 00:00:00 2001 From: Felipe Defensor <87732202+FelipeDefensor@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:02:13 +0200 Subject: [PATCH 1/3] feat: let the score viewer's add and edit commands take the annotation text "Add annotation" and "Edit annotation" always asked for the text in a dialog. They now take it as an optional argument and only ask when it's missing, like the commands in #671. The toolbar and the shortcuts call them without arguments, so they still ask. Editing to an empty text still asks whether to delete the annotation, whether the empty text was typed or passed. --- tilia/ui/windows/svg_viewer.py | 36 ++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tilia/ui/windows/svg_viewer.py b/tilia/ui/windows/svg_viewer.py index 07e874941..c5391e702 100644 --- a/tilia/ui/windows/svg_viewer.py +++ b/tilia/ui/windows/svg_viewer.py @@ -346,18 +346,21 @@ def create_annotation( self.scene.addItem(new_annotation) return new_annotation - def annotation_add(self) -> None: + def annotation_add(self, text: str | None = None) -> None: self.filter_selection(SvgStaveNote) if not (to_add := self.scene.selectedItems()): return - success, annotation = get( - Get.FROM_USER_STRING, "Score Annotation", "Add annotation" - ) - if not success or not annotation: + if text is None: + success, text = get( + Get.FROM_USER_STRING, "Score Annotation", "Add annotation" + ) + if not success: + return + if not text: return for item in to_add: new_annotation = self.create_annotation( - annotation, self.next_tla_id, item.x(), item.y() + text, self.next_tla_id, item.x(), item.y() ) item.setSelected(False) new_annotation.setSelected(True) @@ -384,19 +387,22 @@ def annotation_delete(self) -> None: ) post(Post.APP_STATE_RECORD, "score annotation") - def annotation_edit(self) -> None: + def annotation_edit(self, text: str | None = None) -> None: self.filter_selection(SvgTlaAnnotation) if not (to_edit := self.scene.selectedItems()): return for item in to_edit: - success, annotation = get( - Get.FROM_USER_STRING, - "Score Annotation", - "Edit Annotation", - text=item.text(), - ) - if not success: - continue + if text is None: + success, annotation = get( + Get.FROM_USER_STRING, + "Score Annotation", + "Edit Annotation", + text=item.text(), + ) + if not success: + continue + else: + annotation = text if not annotation: if get( Get.FROM_USER_YES_OR_NO, From da44fabab09f07a98df6784f33fbfe39ace5a6d2 Mon Sep 17 00:00:00 2001 From: Felipe Defensor <87732202+FelipeDefensor@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:02:13 +0200 Subject: [PATCH 2/3] test: cover score timeline regressions Adds regression coverage for: creating a score timeline (once or several times) from the menu bar; setting/resetting the color of several selected notes at once; the note keyboard shortcut for delete doing nothing (score components aren't deletable) while Enter opens the inspector; reloading a saved file keeps notes at the same position; importing a score with no beat timeline shows an error; and importing a score then deleting all beats does not crash, degrading metric position to None instead. --- .../timelines/score/test_score_timeline_ui.py | 175 +++++++++++++++++- 1 file changed, 174 insertions(+), 1 deletion(-) diff --git a/tests/ui/timelines/score/test_score_timeline_ui.py b/tests/ui/timelines/score/test_score_timeline_ui.py index 3308b4ec8..db47bc14f 100644 --- a/tests/ui/timelines/score/test_score_timeline_ui.py +++ b/tests/ui/timelines/score/test_score_timeline_ui.py @@ -14,7 +14,16 @@ patch_file_dialog, patch_yes_or_no_dialog, ) -from tests.utils import get_blank_file_data, reloadable, wait_for_smooth_movement +from tests.ui.timelines.interact import press_key +from tests.utils import ( + get_blank_file_data, + get_command_action, + get_main_window_menu, + get_submenu, + reloadable, + undoable, + wait_for_smooth_movement, +) from tilia.errors import SCORE_STAFF_ID_ERROR from tilia.exceptions import NoReplyToRequest from tilia.parsers.score.musicxml import notes_from_musicXML @@ -24,6 +33,7 @@ from tilia.timelines.score.components import Clef from tilia.timelines.score.timeline import ScoreTimeline from tilia.ui import commands +from tilia.ui.windows import WindowKind def test_create(tluis): @@ -600,3 +610,166 @@ def check_drawn(): assert max(y_by_row["numerator"]) < min(y_by_row["denominator"]) for y in y_by_row.get("between", []): assert max(y_by_row["numerator"]) < y < min(y_by_row["denominator"]) + + +def _add_score_action(qtui): + add_menu = get_submenu(get_main_window_menu(qtui, "Timelines"), "Add") + return get_command_action(add_menu, "timelines.add.score") + + +def test_create_score_timeline_from_menu_bar(qtui, tluis): + # Create a score timeline from the menu bar (Timelines > Add > Score). + action = _add_score_action(qtui) + assert action is not None + + with Serve(Get.FROM_USER_STRING, (True, "")): + with undoable(): + action.trigger() + + assert len(tluis) == 1 + assert len(tluis.get_timeline_uis_by_type(ScoreTimeline)) == 1 + + +def test_create_several_score_timelines_from_menu_bar(qtui, tluis): + # Create several score timelines from the menu bar. + action = _add_score_action(qtui) + + with Serve(Get.FROM_USER_STRING, (True, "")): + for _ in range(3): + action.trigger() + + score_tluis = tluis.get_timeline_uis_by_type(ScoreTimeline) + assert len(score_tluis) == 3 + assert len({tlui.id for tlui in score_tluis}) == 3 + + +def _create_two_notes(score_tl): + score_tl.create_component(ComponentKind.STAFF, 0, 5) + score_tl.create_component(ComponentKind.CLEF, 0, 0, shorthand=Clef.Shorthand.TREBLE) + note1 = score_tl.create_component(ComponentKind.NOTE, 0, 1, 0, 0, 3, 0)[0] + note2 = score_tl.create_component(ComponentKind.NOTE, 1, 2, 2, 0, 3, 0)[0] + return note1, note2 + + +def test_set_color_of_several_notes(score_tlui, score_tl): + # Set the color of several notes at once (context menu / set_color command). + note1, note2 = _create_two_notes(score_tl) + post(Post.SCORE_TIMELINE_COMPONENTS_DESERIALIZED, score_tlui.id) + + note1_ui = score_tlui.get_component_ui(note1) + note2_ui = score_tlui.get_component_ui(note2) + score_tlui.select_element(note1_ui) + score_tlui.select_element(note2_ui) + + commands.execute("timeline.component.set_color", color=QColor("#123456")) + + assert note1_ui.get_data("color") == "#123456" + assert note2_ui.get_data("color") == "#123456" + + +def test_reset_color_of_several_notes(score_tlui, score_tl): + # Reset the color of several notes at once (context menu / reset_color command). + note1, note2 = _create_two_notes(score_tl) + post(Post.SCORE_TIMELINE_COMPONENTS_DESERIALIZED, score_tlui.id) + + note1_ui = score_tlui.get_component_ui(note1) + note2_ui = score_tlui.get_component_ui(note2) + score_tlui.select_element(note1_ui) + score_tlui.select_element(note2_ui) + + commands.execute("timeline.component.set_color", color=QColor("#123456")) + + commands.execute("timeline.component.reset_color") + + assert note1_ui.get_data("color") is None + assert note2_ui.get_data("color") is None + + +def test_delete_note_keyboard_shortcut_does_nothing(score_tlui, score_tl, note_ui): + # Deleting a note with the keyboard shortcut does nothing -- notes + # can't be deleted (score components carry TimelineFlag.COMPONENTS_NOT_DELETABLE). + post(Post.SCORE_TIMELINE_COMPONENTS_DESERIALIZED, score_tlui.id) + score_tlui.select_element(note_ui) + component_count_before = len(score_tl) + + press_key("Delete") + + assert len(score_tl) == component_count_before + assert note_ui.tl_component in score_tl + + +def test_open_inspector_for_note_with_keyboard_shortcut(qtui, score_tlui, note_ui): + # Open the inspector for a note with the keyboard shortcut (Enter/Return). + post(Post.SCORE_TIMELINE_COMPONENTS_DESERIALIZED, score_tlui.id) + score_tlui.select_element(note_ui) + press_key("Return") + + assert qtui.is_window_open(WindowKind.INSPECT) + post(Post.WINDOW_CLOSE, WindowKind.INSPECT) + + +def test_reload_file_places_notes_in_same_position(qtui, score_tl, beat_tl, tmp_path): + # Load an annotation (.tla) file -- notes are placed in about the + # same place as before saving. Reading: "annotation file" = the app's own + # .tla project file (TiLiA = "TimeLine Annotator"); "same place" = same + # vertical (pitch/staff-derived) position for every note, mirroring how + # test_attribute_positions above checks CLEF/KEY_SIGNATURE/TIME_SIGNATURE + # by Y position only -- time-axis (X) placement is not asserted there + # either, since it is derived from time_x_converter's view-width-dependent + # scale rather than from saved data. + beat_tl.beat_pattern = [1] + for i in range(0, 3): + beat_tl.create_beat(i) + beat_tl.measure_numbers = [0, 1, 2] + beat_tl.recalculate_measures() + + notes_from_musicXML(score_tl, beat_tl, EXAMPLE_MULTISTAFF_MUSICXML_PATH) + + def _note_y_positions(): + score = get(Get.TIMELINE_UI_BY_ATTR, "timeline_class", ScoreTimeline) + notes = sorted( + score.timeline.get_components_by_attr("KIND", ComponentKind.NOTE) + ) + assert notes # sanity: the fixture score has notes to compare + return [score.get_element(n.id).top_y for n in notes] + + positions_before = _note_y_positions() + + @reloadable(tmp_path / "file.tla") + def check_positions(): + assert _note_y_positions() == positions_before + + +def test_import_score_with_no_beat_timeline_shows_error(score_tlui, tilia_errors): + # Importing a score with no beat timeline in the file shows an error. + commands.execute("timelines.import.score") + tilia_errors.assert_in_error_title("Import failed") + + +def test_import_score_then_delete_all_beats_does_not_crash( + qtui, score_tl, beat_tl, beat_tlui, tmp_path +): + # Import a score, then delete all beats: no crash. This covers the + # data-layer half of the behavior -- clicking a note in the rendered + # score viewer afterwards needs rendering (see below) and isn't covered + # here. + beat_tl.beat_pattern = [1] + for i in range(0, 3): + beat_tl.create_beat(i) + beat_tl.measure_numbers = [0, 1, 2] + beat_tl.recalculate_measures() + + notes_from_musicXML(score_tl, beat_tl, EXAMPLE_MULTISTAFF_MUSICXML_PATH) + note = score_tl.get_component_by_attr("KIND", ComponentKind.NOTE) + assert note is not None + + with patch_yes_or_no_dialog(True): + commands.execute("timeline.clear", beat_tlui) + + assert len(beat_tl) == 0 + + score = get(Get.TIMELINE_UI_BY_ATTR, "timeline_class", ScoreTimeline) + note_ui = score.get_element(note.id) + # Metric position depends on the beat timeline; with no beats left this + # should degrade to None rather than raise. + assert note_ui.get_data("start_metric_position") is None From bf8b82f34db86fa4a9e855c81d09d891a0371035 Mon Sep 17 00:00:00 2001 From: Felipe Defensor <87732202+FelipeDefensor@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:02:13 +0200 Subject: [PATCH 3/3] test: cover the score viewer window Drives the real SvgViewer end to end: toolbar commands via commands.execute(...), keyboard shortcuts via QTest.keyClick, and mouse/wheel input via real Qt events on the view's viewport. Covers annotations (create/delete/edit via shortcut and toolbar, moving with the mouse, clearing to empty with confirm/decline, font size), window lifecycle (close/reopen), and note interaction (click-to-seek, single/multiple selection, zoom in/out). Sidesteps only the MusicXML -> SVG render step (tilia/parsers/score/musicxml_to_svg.py, which drives a QWebEngineView through VexFlow) by loading a hand-built SVG directly through SvgViewer.load_svg_data -- the same method the app calls once that pipeline hands back its result. The selection highlight is checked through the view: the note's patch of the view is grabbed and searched for the red fill that paint() applies while the item is selected, before and after moving the selection. Going through QGraphicsScene.render instead doesn't reproduce that fill. An annotation added through the toolbar lands within a pixel of where it was after a save-and-open round trip, read back from the rebuilt viewer. --- tests/ui/windows/test_svg_viewer.py | 312 +++++++++++++++++++++++++++- 1 file changed, 311 insertions(+), 1 deletion(-) diff --git a/tests/ui/windows/test_svg_viewer.py b/tests/ui/windows/test_svg_viewer.py index 57db778f1..1ef0f9f0b 100644 --- a/tests/ui/windows/test_svg_viewer.py +++ b/tests/ui/windows/test_svg_viewer.py @@ -1,6 +1,16 @@ +import pytest from lxml import etree +from PySide6.QtCore import QPoint, QPointF, Qt +from PySide6.QtGui import QColor, QWheelEvent +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication -from tilia.ui.windows.svg_viewer import SvgViewer +from tests.mock import Serve +from tests.utils import reloadable, undoable +from tilia.requests import Get, Post, get, post +from tilia.timelines.score.timeline import ScoreTimeline +from tilia.ui import commands +from tilia.ui.windows.svg_viewer import SvgStaveNote, SvgTlaAnnotation, SvgViewer def _make_svg(*texts): @@ -108,3 +118,303 @@ def test_displays_error_when_no_beat_positions_found( tilia_errors.assert_error() tilia_errors.assert_in_error_message("Beat positions not found") assert not tluis.get_timeline_ui(score_tl.id).svg_view.is_svg_loaded + + +# --- Score/"VexFlow" viewer regression tests -------------------------- +# +# These drive the real SvgViewer end to end: toolbar commands via +# commands.execute(...), keyboard shortcuts via QTest.keyClick, and mouse/ +# wheel input via real Qt events on the view's viewport. They sidestep only +# the MusicXML -> SVG render step (tilia/parsers/score/musicxml_to_svg.py, +# which drives a QWebEngineView through VexFlow) by loading a hand-built SVG +# directly through SvgViewer.load_svg_data -- the same method the app calls +# once that pipeline hands back its result. This mirrors how this file's own +# _make_svg already stands in for real VexFlow output further up. + +NOTE_SEEK_XS = [10.0, 100.0, 200.0] + + +def _make_score_svg(note_ids=("note0", "note1", "note2")): + """A minimal SVG with one per note, each with + real geometry so QSvgRenderer.boundsOnElement resolves a rect. This is + enough for SvgViewer.create_stavenotes to build real SvgStaveNote items.""" + notes = "".join( + f'' + f'' + for note_id, x in zip(note_ids, NOTE_SEEK_XS, strict=True) + ) + return ( + '{notes}' + ) + + +@pytest.fixture +def scored_beat_tl(beat_tl): + """Backend beat timeline with 3 beats at t=0,1,2 (measures 0-2, one beat + each), so the viewer's scene-x -> time math resolves to a single, + predictable time per note instead of an empty/ambiguous mapping.""" + beat_tl.beat_pattern = [1] + for i in range(3): + beat_tl.create_beat(i) + beat_tl.measure_numbers = [0, 1, 2] + beat_tl.recalculate_measures() + return beat_tl + + +@pytest.fixture +def svg_viewer(score_tlui, score_tl, scored_beat_tl): + """The real SvgViewer, loaded with synthetic-but-valid SVG data (see + _make_score_svg) instead of a real MusicXML/VexFlow render.""" + score_tl.set_data( + "viewer_beat_x", dict(zip([0.0, 1.0, 2.0], NOTE_SEEK_XS, strict=True)) + ) + viewer = score_tlui.get_or_create_svg_view() + viewer.load_svg_data(_make_score_svg()) + # The dock widget's own isVisible() (and WindowShortcut-context actions + # like "Shift+Return") depend on the *main window* being shown too, not + # just the viewer itself -- mirrors tests.ui.timelines.interact.get_focused_widget. + get(Get.MAIN_WINDOW).show() + viewer.show() + QApplication.processEvents() + # Loading synthetic SVG data (above) mutates timeline.svg_data outside of + # any commands.execute(...) call, so it never reaches the undo manager's + # history. Without this record, undoable() in the tests below would diff + # its own fresh "before" snapshot against the undo manager's stale + # pre-fixture one (empty svg_data) and either crash re-parsing "" as SVG + # or fail the state comparison. + post(Post.APP_STATE_RECORD, "svg_viewer fixture") + return viewer + + +def _stavenotes(viewer: SvgViewer) -> list[SvgStaveNote]: + return sorted( + (i for i in viewer.scene.items() if isinstance(i, SvgStaveNote)), + key=lambda i: i.x(), + ) + + +def _annotations(viewer: SvgViewer) -> list[SvgTlaAnnotation]: + return [i for i in viewer.scene.items() if isinstance(i, SvgTlaAnnotation)] + + +def _add_annotation_via_toolbar(viewer: SvgViewer, text="Allegro") -> SvgTlaAnnotation: + _stavenotes(viewer)[0].setSelected(True) + commands.execute("timeline.score.add", text=text) + return _annotations(viewer)[0] + + +def _looks_highlighted(viewer: SvgViewer, note: SvgStaveNote) -> bool: + """Whether the selection highlight is painted over `note`. + + SvgStaveNote.paint fills the glyph red while it is selected, so this + grabs the note's patch of the view and looks for red pixels instead of + re-reading isSelected(): what this covers is the rectangle the user sees + moving, not the selection state. It has to go through the view rather + than QGraphicsScene.render, which doesn't reproduce the composition mode + paint() uses for the fill. + """ + view = viewer.view + patch = view.mapFromScene(note.sceneBoundingRect()).boundingRect() + image = view.viewport().grab(patch).toImage() + for x in range(image.width()): + for y in range(image.height()): + color = QColor(image.pixel(x, y)) + if color.red() > 150 and color.green() < 100 and color.blue() < 100: + return True + return False + + +class TestAnnotations: + def test_create_annotation_shortcut(self, svg_viewer): + _stavenotes(svg_viewer)[0].setSelected(True) + with undoable(), Serve(Get.FROM_USER_STRING, (True, "Allegro")): + QTest.keyClick(svg_viewer, Qt.Key.Key_Return) + annotations = _annotations(svg_viewer) + assert len(annotations) == 1 + assert annotations[0].text() == "Allegro" + + def test_annotation_keeps_its_place_after_reload(self, svg_viewer, tmp_path): + # Loading a saved file puts each annotation back in roughly the + # place it was left in. Opening a file rebuilds the viewer from the + # saved components, so this reads the positions off the rebuilt + # items rather than the ones the fixture handed out. + _add_annotation_via_toolbar(svg_viewer, "Allegro") + + def positions(): + score = get(Get.TIMELINE_UI_BY_ATTR, "timeline_class", ScoreTimeline) + return { + a.text(): (a.pos().x(), a.pos().y()) + for a in _annotations(score.svg_view) + } + + before = positions() + assert before + + @reloadable(tmp_path / "file.tla") + def check_positions(): + after = positions() + assert after.keys() == before.keys() + for text, position in before.items(): + assert after[text] == pytest.approx(position, abs=1) + + def test_delete_annotation_shortcut(self, svg_viewer): + annotation = _add_annotation_via_toolbar(svg_viewer) + annotation.setSelected(True) + with undoable(): + QTest.keyClick(svg_viewer, Qt.Key.Key_Delete) + assert _annotations(svg_viewer) == [] + + def test_edit_annotation_shortcut(self, svg_viewer): + annotation = _add_annotation_via_toolbar(svg_viewer) + annotation.setSelected(True) + with undoable(), Serve(Get.FROM_USER_STRING, (True, "Andante")): + QTest.keyClick( + svg_viewer, Qt.Key.Key_Return, Qt.KeyboardModifier.ShiftModifier + ) + assert annotation.text() == "Andante" + + def test_move_annotation_with_mouse(self, svg_viewer): + annotation = _add_annotation_via_toolbar(svg_viewer) + view = svg_viewer.view + press_pt = view.mapFromScene(annotation.sceneBoundingRect().center()) + release_pt = press_pt + QPoint(40, 15) + before = (annotation.x(), annotation.y()) + with undoable(): + QTest.mousePress(view.viewport(), Qt.MouseButton.LeftButton, pos=press_pt) + QTest.mouseMove(view.viewport(), pos=release_pt) + QTest.mouseRelease( + view.viewport(), Qt.MouseButton.LeftButton, pos=release_pt + ) + after = (annotation.x(), annotation.y()) + assert after != before + + def test_edit_annotation_to_empty_deletes_it_toolbar(self, svg_viewer): + # Reading: clearing the text hits the "no text inputted" prompt; + # confirming it deletes the annotation. + annotation = _add_annotation_via_toolbar(svg_viewer) + annotation.setSelected(True) + with ( + undoable(), + Serve(Get.FROM_USER_YES_OR_NO, True), + ): + commands.execute("timeline.score.edit", text="") + assert _annotations(svg_viewer) == [] + + def test_edit_annotation_to_empty_kept_if_declined_toolbar(self, svg_viewer): + # Same flow, declining the confirmation: annotation must survive. + annotation = _add_annotation_via_toolbar(svg_viewer) + annotation.setSelected(True) + with (Serve(Get.FROM_USER_YES_OR_NO, False),): + commands.execute("timeline.score.edit", text="") + assert _annotations(svg_viewer) == [annotation] + + def test_increase_annotation_font_toolbar(self, svg_viewer): + annotation = _add_annotation_via_toolbar(svg_viewer) + annotation.setSelected(True) + size_before = annotation.font().pointSize() + with undoable(): + commands.execute("timeline.score.font_inc") + assert annotation.font().pointSize() == size_before * 2 + + def test_decrease_annotation_font_toolbar(self, svg_viewer): + annotation = _add_annotation_via_toolbar(svg_viewer) + annotation.setSelected(True) + size_before = annotation.font().pointSize() + with undoable(): + commands.execute("timeline.score.font_dec") + assert annotation.font().pointSize() == size_before // 2 + + +class TestWindowLifecycle: + def test_close_viewer(self, svg_viewer): + assert svg_viewer.isVisible() + svg_viewer.close() + assert not svg_viewer.isVisible() + + def test_reopen_viewer(self, svg_viewer): + # Mirrors TestViewWindow.test_video_window_reopens_on_show_request in + # test_windows.py: closing only hides (ViewWidget.closeEvent ignores + # the event), and the window menu reopens it via WINDOW_UPDATE_REQUEST. + svg_viewer.close() + assert not svg_viewer.isVisible() + post(Post.WINDOW_UPDATE_REQUEST, svg_viewer.id, True) + assert svg_viewer.isVisible() + + +class TestNoteInteraction: + def test_click_note_seeks_playback(self, svg_viewer, tilia_state): + tilia_state.current_time = 0 + note = _stavenotes(svg_viewer)[1] # seek_x=100 -> beat 1 -> t=1.0 + view = svg_viewer.view + pos = view.mapFromScene(note.sceneBoundingRect().center()) + QTest.mouseDClick(view.viewport(), Qt.MouseButton.LeftButton, pos=pos) + assert tilia_state.current_time == pytest.approx(1.0) + + def test_highlight_follows_the_selected_note(self, svg_viewer): + # Clicking a note paints the highlight over it, and clicking + # another moves the highlight along with the selection. + notes = _stavenotes(svg_viewer) + view = svg_viewer.view + assert not any(_looks_highlighted(svg_viewer, note) for note in notes) + + for selected, other in ((0, 1), (1, 0)): + pos = view.mapFromScene(notes[selected].sceneBoundingRect().center()) + QTest.mouseClick(view.viewport(), Qt.MouseButton.LeftButton, pos=pos) + QApplication.processEvents() + + assert _looks_highlighted(svg_viewer, notes[selected]) + assert not _looks_highlighted(svg_viewer, notes[other]) + + def test_select_one_note(self, svg_viewer): + notes = _stavenotes(svg_viewer) + view = svg_viewer.view + pos = view.mapFromScene(notes[0].sceneBoundingRect().center()) + QTest.mouseClick(view.viewport(), Qt.MouseButton.LeftButton, pos=pos) + assert svg_viewer.scene.selectedItems() == [notes[0]] + + def test_select_several_notes(self, svg_viewer): + # Reading: ctrl-click extends the selection, as for any other + # QGraphicsScene item -- there is no separate "select all" gesture + # for stave notes. + notes = _stavenotes(svg_viewer) + view = svg_viewer.view + pos0 = view.mapFromScene(notes[0].sceneBoundingRect().center()) + pos1 = view.mapFromScene(notes[1].sceneBoundingRect().center()) + QTest.mouseClick(view.viewport(), Qt.MouseButton.LeftButton, pos=pos0) + QTest.mouseClick( + view.viewport(), + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.ControlModifier, + pos1, + ) + assert set(svg_viewer.scene.selectedItems()) == {notes[0], notes[1]} + + +class TestZoom: + @staticmethod + def _wheel_zoom(view, angle_delta_y: int) -> None: + center = view.viewport().rect().center() + event = QWheelEvent( + QPointF(center), + QPointF(view.viewport().mapToGlobal(center)), + QPoint(0, 0), + QPoint(0, angle_delta_y), + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.ControlModifier, + Qt.ScrollPhase.NoScrollPhase, + False, + ) + QApplication.sendEvent(view.viewport(), event) + + def test_zoom_in(self, svg_viewer): + view = svg_viewer.view + scale_before = view.transform().m11() + self._wheel_zoom(view, 120) + assert view.transform().m11() > scale_before + + def test_zoom_out(self, svg_viewer): + view = svg_viewer.view + scale_before = view.transform().m11() + self._wheel_zoom(view, -120) + assert view.transform().m11() < scale_before