Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
b8d15ef
refactor: hardcoded values
FelipeDefensor Jul 26, 2025
27f7e75
chore: auto-format
FelipeDefensor Jul 27, 2025
914a0bd
refactor: remove unused code
FelipeDefensor Aug 14, 2025
6964fdd
fix: do not usable mutable default argument
FelipeDefensor Aug 26, 2025
7c3f233
fix: crash when creating score annotation
FelipeDefensor Sep 5, 2025
3be8cee
fix: crash when loading svg viewer without beat timeline
FelipeDefensor Nov 7, 2025
24576bd
fix: restore state with identical components but different ids
FelipeDefensor Nov 6, 2025
f07fd4d
test: record state after creating timelines with fixtures
FelipeDefensor Nov 13, 2025
974e715
refactor: rename Post.APP_RECORD_STATE to Post.APP_STATE_RECORD
FelipeDefensor Nov 13, 2025
467cb5e
test: set timeline visibility
FelipeDefensor Nov 7, 2025
4f4be13
fix: color validator
FelipeDefensor Nov 7, 2025
769e30b
refactor: start using {Timeline,TimelineUI}.subclasses instead of tim…
FelipeDefensor Aug 26, 2025
f13fa08
refactor: use Timeline.flags instead of individual class attributes
FelipeDefensor Aug 26, 2025
4c534a8
refactor: commands APIS [MAJOR]
FelipeDefensor Nov 25, 2025
63abd6b
fix: detection of empty timelines
FelipeDefensor Nov 12, 2025
d33d581
refactor: take "time" parameters to on_add method of point-like Timel…
FelipeDefensor Nov 12, 2025
1a31ad5
test: fix errors on test_dirs.py
FelipeDefensor Nov 25, 2025
ae05c63
fix: duplicated error message
FelipeDefensor Nov 26, 2025
7e64d1f
fix: error message wording
FelipeDefensor Nov 26, 2025
45dd1da
docs: add debug tip
FelipeDefensor Nov 26, 2025
0fae6ec
fix: undo/redo when importing
FelipeDefensor Nov 26, 2025
cfa4ab0
refactor: remove dead code
FelipeDefensor Nov 26, 2025
7074f76
fix: toggle timeline visibility
FelipeDefensor Feb 3, 2026
817a0ad
dev: clearer "Unregistered command" error output
FelipeDefensor Feb 5, 2026
b13eb27
fix: preserve timeline ids when opening files and undoing/redoing
FelipeDefensor Feb 5, 2026
cae09c6
fix: do not crash on race condition for creation of dirs.SITE_DATA_DIR
FelipeDefensor Feb 5, 2026
0b99b59
test: use deepdiff to print mismatching app states
FelipeDefensor Feb 5, 2026
6eb1f2a
fix: crash when using timeline context menus
FelipeDefensor Feb 5, 2026
b90e4a0
fix: crash when deleting beat timeline after score timeline has been …
FelipeDefensor Feb 5, 2026
1c5a8ad
fix: "//" in YouTube URL being replaced with "/" when saving
FelipeDefensor Feb 10, 2026
6d9a8a2
refactor: use patch_yes_or_no instead of Serve
FelipeDefensor Feb 17, 2026
1b6af29
fix: crash when timeline folder doesn't have the right structure
FelipeDefensor Feb 27, 2026
8500205
test: fix failing tests on Linux and macOS
FelipeDefensor Feb 27, 2026
c28cf26
test: fix failing tests on Linux and macOS
FelipeDefensor Feb 27, 2026
fb912be
refactor: remove unused posts
azfoo Mar 4, 2026
9db3142
refactor: actually call the post
azfoo Mar 4, 2026
7108397
refactor: remove duplicate
azfoo Mar 4, 2026
946d599
refactor: remove unused gets
azfoo Mar 4, 2026
9633a12
refactor: remove unused tl selectors
azfoo Mar 4, 2026
9bfb4c4
refactor: correct method name
azfoo Mar 4, 2026
305f6d5
fix: restore missing inspector value
azfoo Mar 4, 2026
99c927f
refactor: Post.MEDIA_TOGGLE_PLAY -> "media.toggle_play" command
FelipeDefensor Mar 6, 2026
a4b5626
refactor: Post.MEDIA_SEEK -> "media.seek" command
FelipeDefensor Mar 7, 2026
ec9223d
refactor: use "media.seek" command instead of tilia_state.current_tim…
FelipeDefensor Mar 7, 2026
4b0d1f0
test: double clicking pdf marker
FelipeDefensor Mar 7, 2026
cc1d14b
refactor: receive arguments from "timeline.pdf.add" command
FelipeDefensor Mar 7, 2026
a6da69c
test: double clicking harmony timeline components
FelipeDefensor Mar 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
The test suite is written in pytest. Below are some things to keep in my mind when writing tests. For examples of good and thorough tests, see `tests\ui\timelines\test_marker_timeline_ui.py`. Older modules should be refactored at some point to follow the guidelines below.
## How to simulate interaction with the UI?
- The `user_actions` fixture can be used to trigger actions on the UI. This is equivalent to pressing buttons on the UI. We should also check that the actions are available in the UI where we expect them.
- The `tilia_state` fixture can be used to make certain changes to state simulating user input (e.g. `tilia_state.current_time = 10`).
- The `tilia_state` fixture can be used to make certain changes to state simulating user input (e.g. `tilia_state.duration = 10`.)
- The `press_key` and `type_string` functions can be used to simulate keyboard input.

### Modal dialogs
Unfortunately, we can't simulate input to modal dialogs, as they block execution. To work around that, we can:
- Mock methods of the modal dialogs (e.g. `QInputDialog.getInt`). There are utility functions that do that in some cases (e.g. `tests.utils.patch_file_dialog`)
- If the dialog is called in response to a `Get` request, the `Serve` context manager can be used to mock the return value of the request. E.g.:

```python
with Serve(Get.FROM_USER_INT, (True, 150)):
user_actions.trigger(TiliaAction.TIMELINE_HEIGHT_SET)
commands.execute("timeline_height_set")
```

We should prefer the first option as it makes the test cover more code, but the second is more resilient to changes in implementation details.
Expand All @@ -23,18 +24,19 @@ Some known modal dialogs:
An alternative to mocking modal dialogs would be appreciated. Experiments with mocking modal dialogs (to date) have not worked.

## How to simulate interaction with timelines?
We shouldn't use methods of the `Timeline` or the `TimelineUI` classes, but instead try to simulate user input. This makes for tests that are more resilient to changes in implementation. For instance, this:
We shouldn't use methods of the `Timeline` or the `TimelineUI` classes, but instead try to simulate user input or use commands. This makes for tests that are more resilient to changes in implementation. For instance, this:
```python
def test_me(tlui, marker_tlui):
tlui.create_marker(0)
assert len(marker_tlui) == 1
```

can be rewritten as:

```python
def test_me(marker_tlui, user_actions, tilia_state):
tilia_state.current_time = 0
user_action.trigger(TiliaAction.MARKER_ADD)
def test_me(marker_tlui):
commands.execute("media.seek", 0)
commands.execute("marker_add")
assert not len(marker_tlui) == 1
```
You will find many examples of the former in the test suite, though. Refactors are welcome.
Expand Down
54 changes: 5 additions & 49 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import tilia.settings as settings_module
from tilia.dirs import PROJECT_ROOT
from tilia.media.player.base import MediaTimeChangeReason
from tilia.ui import actions as tilia_actions_module
from tilia.app import App
from tilia.boot import setup_logic
from tilia.requests import (
Expand All @@ -25,7 +24,6 @@
get,
listen,
)
from tilia.ui.actions import TiliaAction, setup_actions
from tilia.ui.qtui import QtUI, TiliaMainWindow
from tilia.ui.cli.ui import CLI
from tilia.ui.windows import WindowKind
Expand Down Expand Up @@ -92,7 +90,11 @@ def __init__(self, tilia: App, player):
def reset(self):
self.app.on_clear()
self.duration = 100
self.current_time = 0

# reset current time
self.player.current_time = 0
post(Post.PLAYER_CURRENT_TIME_CHANGED, 0, MediaTimeChangeReason.PLAYBACK)

self.media_path = ""
self._reset_undo_manager()
post(Post.REQUEST_CLEAR_UI)
Expand Down Expand Up @@ -227,8 +229,6 @@ def qtui(tilia, cleanup_requests, qapplication, use_test_settings, use_test_logg
# noinspection PyProtectedMember
@pytest.fixture(scope="module")
def tilia(cleanup_requests):
mw = TiliaMainWindow()
setup_actions(mw)
tilia_ = setup_logic(autosaver=False)
tilia_.set_file_media_duration(100)
tilia_.reset_undo_manager()
Expand Down Expand Up @@ -278,50 +278,6 @@ def tlui(
}[request.param]


class UserActionManager:
"""
Class to simulate and mock user interaction with the GUI.
"""

def __init__(self):
self.action_to_trigger_count = {}
for action in tilia_actions_module.TiliaAction:
qaction = tilia_actions_module.get_qaction(action)
qaction.triggered.connect(
functools.partial(self._increment_trigger_count, action)
)

def trigger(self, action: TiliaAction):
tilia_actions_module.trigger(action)
self._increment_trigger_count(action)

def _increment_trigger_count(self, action):
if action not in self.action_to_trigger_count:
self.action_to_trigger_count[action] = 1
else:
self.action_to_trigger_count[action] += 1

def assert_triggered(self, action):
assert action in self.action_to_trigger_count

def assert_not_triggered(self, action):
assert action not in self.action_to_trigger_count


@pytest.fixture
def user_actions():
"""
Fixture to simulate and mock user interaction with the GUI.
This should be used with the <kind>_tlui fixtures
and NOT with <kind>_tl fixtures, as the latter do not
create corresponding TimelineUIS. Tests with <kind>_tl
may still pass if they use this fixture, provided they do
not require a TimelineUI.
"""
action_manager = UserActionManager()
yield action_manager


def parametrize_tl(func):
"""Adds a parameter 'tl' to a test that receives the name of a fixture that returns a component.
To get the timeline from within the test, add the `request` fixture to its arguments and
Expand Down
40 changes: 18 additions & 22 deletions tests/file/test_file_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from tilia.file.file_manager import FileManager
from unittest.mock import patch

from tilia.ui.actions import TiliaAction
from tilia.ui import commands


def get_empty_save_params():
Expand All @@ -25,23 +25,23 @@ def get_empty_save_params():
} | {"timelines_hash": ""}


class TestUserActions:
def test_save(self, tls, marker_tlui, tmp_path, user_actions):
class Tests:
def test_save(self, tls, marker_tlui, tmp_path):
marker_tlui.create_marker(0)
tmp_file_path = (tmp_path / "test_save.tla").resolve().__str__()
with patch_file_dialog(True, [tmp_file_path]):
user_actions.trigger(TiliaAction.FILE_SAVE_AS)
commands.execute("file.save_as")
marker_tlui.create_marker(1)
user_actions.trigger(TiliaAction.FILE_SAVE)
commands.execute("file.save")

with patch_yes_or_no_dialog(True):
user_actions.trigger(TiliaAction.TIMELINES_CLEAR)
commands.execute("timelines.clear_all")
assert marker_tlui.is_empty
with (
patch_file_dialog(True, [tmp_file_path]),
patch_yes_or_no_dialog(False), # do not save changes
):
user_actions.trigger(TiliaAction.FILE_OPEN)
commands.execute("file.open")
assert len(tls[0]) == 2


Expand Down Expand Up @@ -93,8 +93,8 @@ def test_is_file_modified_when_modified_timelines(
self, tilia, marker_tlui, tmp_path
):
with Serve(Get.FROM_USER_SAVE_PATH_TILIA, (True, tmp_path / "temp.tla")):
post(Post.FILE_SAVE)
post(Post.MARKER_ADD)
commands.execute("file.save")
commands.execute("timeline.marker.add")
assert tilia.file_manager.is_file_modified(get(Get.APP_STATE))

def test_is_file_modified_modified_tile(self, tilia):
Expand Down Expand Up @@ -144,13 +144,13 @@ def test_import_metadata_file_does_not_exist(self, tilia):
@pytest.mark.skipif(
not sys.platform.startswith("win"), reason="Windows specific test"
)
def test_is_saved_with_posix_paths(self, qtui, tilia, tmp_path, user_actions):
def test_is_saved_with_posix_paths(self, qtui, tilia, tmp_path):
file_path = tmp_path / "test.tla"
with patch_file_dialog(True, [str(EXAMPLE_MEDIA_PATH)]):
post(Post.UI_MEDIA_LOAD_LOCAL)
commands.execute("media.load.local")

with patch_file_dialog(True, [str(WindowsPath(file_path))]):
user_actions.trigger(TiliaAction.FILE_SAVE)
commands.execute("file.save")

with open(file_path, "r") as f:
data = json.load(f)
Expand All @@ -159,30 +159,26 @@ def test_is_saved_with_posix_paths(self, qtui, tilia, tmp_path, user_actions):
assert data["media_path"] == Path(EXAMPLE_MEDIA_PATH).as_posix()

def test_save_without_set_title_and_different_file_name(
self, qtui, tilia, tmp_path, user_actions
self, qtui, tilia, tmp_path
):
tla_path = tmp_path / "Some Title.tla"
with patch_file_dialog(True, [str(tla_path)]):
user_actions.trigger(TiliaAction.FILE_SAVE)
commands.execute("file.save")

assert tilia.file_manager.file.media_metadata["title"] == "Some Title"

def test_save_with_title_set_and_different_file_name(
self, qtui, tilia, tmp_path, user_actions
):
def test_save_with_title_set_and_different_file_name(self, qtui, tilia, tmp_path):
tilia.file_manager.file.media_metadata["title"] = "Title Already Set"
tla_path = tmp_path / "Some Title.tla"
with patch_file_dialog(True, [str(tla_path)]):
user_actions.trigger(TiliaAction.FILE_SAVE)
commands.execute("file.save")

assert tilia.file_manager.file.media_metadata["title"] == "Title Already Set"

def test_save_fail_reverts_to_original_name(
self, qtui, tilia, tmp_path, user_actions
):
def test_save_fail_reverts_to_original_name(self, qtui, tilia, tmp_path):
tla_path = tmp_path / "Non-existent Path" / "Some Other Title.tla"
with patch_file_dialog(True, [str(tla_path)]):
user_actions.trigger(TiliaAction.FILE_SAVE)
commands.execute("file.save")

assert tilia.file_manager.file.media_metadata[
"title"
Expand Down
50 changes: 0 additions & 50 deletions tests/mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,9 @@
from PyQt6.QtWidgets import QFileDialog, QMessageBox, QInputDialog

from tilia.requests import Get, Post, serve, server, stop_serving
from tilia.requests import get as get_original
from tilia.requests import post as post_original


class PatchGetMultiple:
def __init__(self, module: str, requests_to_return_values: dict[Get, Any]):
self.module = module
self.requests_to_return_values = requests_to_return_values
self.mock_request = None
self.mock = None

def __enter__(self):
self.patcher = patch(self.module + ".get", self.request_mock)
self.patcher.start()
self.mock = Mock()
return self.mock

def __exit__(self, exc_type, exc_val, exc_tb):
self.patcher.stop()

def request_mock(self, request: Get, *args, **kwargs):
if request in self.requests_to_return_values:
self.mock(request, *args, **kwargs)
return self.requests_to_return_values[request]
else:
return get_original(request, *args, **kwargs)


class Serve:
def __init__(self, request: Get, return_value: Any):
self.request = request
Expand Down Expand Up @@ -83,31 +58,6 @@ def _callback(self, *_, **__):
return return_value


class PatchGet:
def __init__(self, module: str, request_to_mock: Get, return_value: Any):
self.module = module
self.request_to_mock = request_to_mock
self.return_value = return_value
self.mock_request = None
self.mock = None

def __enter__(self):
self.patcher = patch(self.module + ".get", self.request_mock)
self.patcher.start()
self.mock = Mock()
return self.mock

def __exit__(self, exc_type, exc_val, exc_tb):
self.patcher.stop()

def request_mock(self, request: Get, *args, **kwargs):
if request == self.request_to_mock:
self.mock(request, *args, **kwargs)
return self.return_value
else:
return get_original(request, *args, **kwargs)


class PatchPost:
def __init__(self, module: str, request_to_mock: Post):
self.module = module
Expand Down
Loading