chore: tests clean up
This commit is contained in:
parent
2ab0db93dc
commit
7ac7039592
49 changed files with 535 additions and 595 deletions
|
|
@ -53,7 +53,7 @@ class TestAllowInternalUrlsScope:
|
|||
Config._reset_singleton()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conditions_test_rejects_internal_url_when_disallowed(self) -> None:
|
||||
async def test_rejects_internal_url(self) -> None:
|
||||
config = Config.get_instance()
|
||||
config.allow_internal_urls = False
|
||||
encoder = Encoder()
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ class TestPresetsRepository:
|
|||
assert total_pages == 1, "Should compute pages from the filtered total"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated_supports_multiple_sort_fields(self, repo):
|
||||
async def test_list_paginated_multi_sort(self, repo):
|
||||
await repo.create({"name": "Charlie", "priority": 2})
|
||||
await repo.create({"name": "Alpha", "priority": 1})
|
||||
await repo.create({"name": "Bravo", "priority": 1})
|
||||
|
|
@ -113,24 +113,24 @@ class TestPresetsRepository:
|
|||
], "Should support multiple sort fields and directions"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated_rejects_invalid_sort_field(self, repo):
|
||||
async def test_list_paginated_bad_sort(self, repo):
|
||||
with pytest.raises(ValueError, match="sort must use supported fields"):
|
||||
await repo.list_paginated(page=1, per_page=10, sort="cli", order="asc")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated_rejects_invalid_sort_direction(self, repo):
|
||||
async def test_list_paginated_bad_order(self, repo):
|
||||
with pytest.raises(ValueError, match="order must be 'asc' or 'desc'"):
|
||||
await repo.list_paginated(page=1, per_page=10, sort="name", order="sideways")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated_rejects_mismatched_sort_and_order_lengths(self, repo):
|
||||
async def test_list_paginated_mismatched_order(self, repo):
|
||||
with pytest.raises(ValueError, match="order must provide one direction or match the number of sort fields"):
|
||||
await repo.list_paginated(page=1, per_page=10, sort="priority,name", order="asc,desc,asc")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestPresetRoutes:
|
||||
async def test_list_route_supports_sort_params(self, repo):
|
||||
async def test_list_route_sort(self, repo):
|
||||
await repo.create({"name": "Alpha", "priority": 1})
|
||||
await repo.create({"name": "Bravo", "priority": 1})
|
||||
await repo.create({"name": "Charlie", "priority": 2})
|
||||
|
|
@ -144,7 +144,7 @@ class TestPresetRoutes:
|
|||
assert response.status == web.HTTPOk.status_code, "Should return 200 for valid sorting"
|
||||
assert [item["name"] for item in payload["items"]] == ["bravo", "alpha", "charlie"], "Should sort response"
|
||||
|
||||
async def test_list_route_rejects_invalid_sort_field(self, repo):
|
||||
async def test_list_route_bad_sort(self, repo):
|
||||
request = MagicMock(spec=Request)
|
||||
request.query = {"sort": "cli", "order": "asc"}
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ class TestPresetRoutes:
|
|||
assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported sort field"
|
||||
assert "sort" in payload["error"], "Should explain invalid sort field"
|
||||
|
||||
async def test_list_route_rejects_invalid_sort_direction(self, repo):
|
||||
async def test_list_route_bad_order(self, repo):
|
||||
request = MagicMock(spec=Request)
|
||||
request.query = {"sort": "name", "order": "sideways"}
|
||||
|
||||
|
|
@ -164,7 +164,7 @@ class TestPresetRoutes:
|
|||
assert response.status == web.HTTPBadRequest.status_code, "Should reject unsupported sort direction"
|
||||
assert "order" in payload["error"], "Should explain invalid sort direction"
|
||||
|
||||
async def test_list_route_supports_excluding_defaults(self, repo):
|
||||
async def test_list_route_exclude_defaults(self, repo):
|
||||
await repo.create({"name": "System Default", "default": True, "priority": 10})
|
||||
await repo.create({"name": "Custom Preset", "priority": 1})
|
||||
|
||||
|
|
|
|||
|
|
@ -84,13 +84,11 @@ class TestFFProbe:
|
|||
|
||||
# First call should execute the function
|
||||
result1 = await ffprobe(str(self.test_file))
|
||||
assert result1 is not None
|
||||
assert isinstance(result1.metadata, dict)
|
||||
first_call_count = call_count
|
||||
|
||||
# Second call with same argument should use cached result
|
||||
result2 = await ffprobe(str(self.test_file))
|
||||
assert result2 is not None
|
||||
assert isinstance(result2.metadata, dict)
|
||||
|
||||
assert call_count == first_call_count, (
|
||||
|
|
@ -119,7 +117,7 @@ class TestFFProbe:
|
|||
|
||||
# Test with Path object
|
||||
result = await ffprobe(self.test_file)
|
||||
assert result is not None
|
||||
assert result.metadata == {"duration": "10.0"}
|
||||
|
||||
def test_ffprobe_result_properties(self):
|
||||
"""Test FFProbeResult object properties."""
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ async def test_make_stream_basic_ok_codecs(tmp_path: Path, monkeypatch: pytest.M
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_stream_transcode_flags_and_remainder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_make_stream_transcode_flags(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
base = tmp_path / "dl"
|
||||
base.mkdir()
|
||||
media = base / "v.mp4"
|
||||
|
|
@ -107,7 +107,7 @@ async def test_make_stream_transcode_flags_and_remainder(tmp_path: Path, monkeyp
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_stream_raises_without_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_make_stream_no_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
base = tmp_path / "dl"
|
||||
base.mkdir()
|
||||
media = base / "v.mp4"
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_playlist_raises_without_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_make_playlist_no_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
base = tmp_path / "downloads"
|
||||
base.mkdir()
|
||||
media = base / "file.mp4"
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ class _FakeProcFail(_FakeProc):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_ffmpeg_args_no_dri_falls_back_to_software(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_build_ffmpeg_args_no_dri(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
media = tmp_path / "file.mp4"
|
||||
media.write_bytes(b"data")
|
||||
|
||||
|
|
@ -219,7 +219,7 @@ async def test_build_ffmpeg_args_no_dri_falls_back_to_software(tmp_path: Path, m
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_gpu_failure_falls_back_to_software(
|
||||
async def test_stream_gpu_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
async def fake_ffprobe(_file: Path):
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ async def test_make_vtt_reads_file(tmp_path: Path) -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_delivery_returns_raw_ass_with_ass_content_type(tmp_path: Path) -> None:
|
||||
async def test_make_delivery_ass(tmp_path: Path) -> None:
|
||||
ass = tmp_path / "file.ass"
|
||||
content = "[Script Info]\nTitle: Demo\n"
|
||||
ass.write_text(content, encoding="utf-8")
|
||||
|
|
@ -55,7 +55,7 @@ async def test_make_delivery_returns_raw_ass_with_ass_content_type(tmp_path: Pat
|
|||
assert media_type == "text/x-ssa; charset=UTF-8"
|
||||
|
||||
|
||||
def test_get_subtitle_tracks_prefers_native_then_ass(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_tracks_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
media = tmp_path / "video.mkv"
|
||||
media.write_text("x", encoding="utf-8")
|
||||
ass_file = tmp_path / "video.ass"
|
||||
|
|
@ -107,7 +107,7 @@ async def test_make_no_events_raises(tmp_path: Path) -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_single_event_returns_vtt(tmp_path: Path) -> None:
|
||||
async def test_make_single_vtt(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.ass"
|
||||
srt.write_text("dummy")
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ async def test_make_single_event_returns_vtt(tmp_path: Path) -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_two_events_pop_first_when_ends_equal(tmp_path: Path) -> None:
|
||||
async def test_make_two_events_same_end(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.srt"
|
||||
srt.write_text("dummy")
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ async def test_make_two_events_pop_first_when_ends_equal(tmp_path: Path) -> None
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_two_events_no_pop_when_different(tmp_path: Path) -> None:
|
||||
async def test_make_two_events_keep_both(tmp_path: Path) -> None:
|
||||
srt = tmp_path / "sub.srt"
|
||||
srt.write_text("dummy")
|
||||
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@ def _make_request(path: str, *, match_info: dict[str, str]) -> web.Request:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subtitles_manifest_lists_tracks_in_native_first_order(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def test_subtitles_manifest_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = Config.get_instance()
|
||||
config.download_path = str(tmp_path)
|
||||
|
||||
|
|
@ -79,7 +77,6 @@ async def test_subtitles_manifest_lists_tracks_in_native_first_order(
|
|||
response = await router.subtitles_manifest_get(req, config, req.app)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code
|
||||
assert response.body is not None
|
||||
body = response.text
|
||||
assert '"source_format": "vtt"' in body
|
||||
assert '"renderer": "native"' in body
|
||||
|
|
@ -89,7 +86,7 @@ async def test_subtitles_manifest_lists_tracks_in_native_first_order(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subtitles_track_get_returns_raw_ass_delivery(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_subtitles_track_get_ass(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = Config.get_instance()
|
||||
config.download_path = str(tmp_path)
|
||||
|
||||
|
|
@ -113,9 +110,7 @@ async def test_subtitles_track_get_returns_raw_ass_delivery(tmp_path: Path, monk
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subtitles_track_get_rejects_mismatched_source_format(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def test_subtitles_track_bad_format(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = Config.get_instance()
|
||||
config.download_path = str(tmp_path)
|
||||
|
||||
|
|
@ -134,5 +129,4 @@ async def test_subtitles_track_get_rejects_mismatched_source_format(
|
|||
response = await router.subtitles_track_get(req, config, req.app)
|
||||
|
||||
assert response.status == web.HTTPBadRequest.status_code
|
||||
assert response.body is not None
|
||||
assert b"does not match requested source format" in response.body
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ def reset_generic_handler(monkeypatch):
|
|||
monkeypatch.setattr(GenericTaskHandler, "_sources_mtime", {})
|
||||
|
||||
|
||||
def test_build_task_definition_parses_valid_payload():
|
||||
def test_build_def_payload():
|
||||
definition = TaskDefinition(
|
||||
id=1,
|
||||
name="example",
|
||||
|
|
@ -40,16 +40,12 @@ def test_build_task_definition_parses_valid_payload():
|
|||
),
|
||||
)
|
||||
|
||||
assert definition is not None, "TaskDefinition should be created"
|
||||
assert "example" == definition.name, "Name should match"
|
||||
assert "https://example.com/articles/*" in definition.match_url, "Match URL should be in list"
|
||||
assert "link" in definition.definition.parse, "Parse should contain link field"
|
||||
assert ".article a.link::attr(href)" == definition.definition.parse["link"]["expression"], (
|
||||
"Link expression should match"
|
||||
)
|
||||
assert definition.name == "example"
|
||||
assert definition.match_url == ["https://example.com/articles/*"]
|
||||
assert definition.definition.parse["link"]["expression"] == ".article a.link::attr(href)"
|
||||
|
||||
|
||||
def test_build_task_definition_handles_container():
|
||||
def test_build_def_container():
|
||||
definition = TaskDefinition(
|
||||
id=2,
|
||||
name="container",
|
||||
|
|
@ -73,13 +69,11 @@ def test_build_task_definition_handles_container():
|
|||
),
|
||||
)
|
||||
|
||||
assert definition is not None, "TaskDefinition should be created"
|
||||
assert "items" in definition.definition.parse, "Parse should contain items container"
|
||||
assert ".cards .card" == definition.definition.parse["items"]["selector"], "Items selector should match"
|
||||
assert "link" in definition.definition.parse["items"]["fields"], "Items fields should contain link"
|
||||
assert definition.definition.parse["items"]["selector"] == ".cards .card"
|
||||
assert definition.definition.parse["items"]["fields"]["link"]["attribute"] == "href"
|
||||
|
||||
|
||||
def test_build_task_definition_handles_json():
|
||||
def test_build_def_json():
|
||||
definition = TaskDefinition(
|
||||
id=3,
|
||||
name="json-def",
|
||||
|
|
@ -104,16 +98,12 @@ def test_build_task_definition_handles_json():
|
|||
),
|
||||
)
|
||||
|
||||
assert definition is not None, "TaskDefinition should be created"
|
||||
assert "json" == definition.definition.response.type, "Response type should be json"
|
||||
assert "items" in definition.definition.parse, "Parse should contain items container"
|
||||
assert "jsonpath" == definition.definition.parse["items"]["type"], "Items type should be jsonpath"
|
||||
assert "jsonpath" == definition.definition.parse["items"]["fields"]["link"]["type"], (
|
||||
"Link field type should be jsonpath"
|
||||
)
|
||||
assert definition.definition.response.type == "json"
|
||||
assert definition.definition.parse["items"]["type"] == "jsonpath"
|
||||
assert definition.definition.parse["items"]["fields"]["link"]["type"] == "jsonpath"
|
||||
|
||||
|
||||
def test_parse_items_extracts_values():
|
||||
def test_parse_items_basic():
|
||||
definition = TaskDefinition(
|
||||
id=4,
|
||||
name="example",
|
||||
|
|
@ -146,14 +136,16 @@ def test_parse_items_extracts_values():
|
|||
|
||||
items = GenericTaskHandler._parse_items(definition, html, "https://example.com/base/")
|
||||
|
||||
assert 2 == len(items), "Should extract 2 items"
|
||||
assert "https://example.com/article-101" == items[0]["link"], "First item link should be absolute URL"
|
||||
assert "First Title" == items[0]["title"], "First item title should match"
|
||||
assert "101" == items[0]["id"], "First item id should match"
|
||||
assert "https://example.com/article-102" == items[1]["link"], "Second item link should match"
|
||||
assert len(items) == 2
|
||||
assert items[0] == {
|
||||
"link": "https://example.com/article-101",
|
||||
"title": "First Title",
|
||||
"id": "101",
|
||||
}
|
||||
assert items[1]["link"] == "https://example.com/article-102"
|
||||
|
||||
|
||||
def test_parse_items_handles_nested_card_layout():
|
||||
def test_parse_items_cards():
|
||||
definition = TaskDefinition(
|
||||
id=5,
|
||||
name="nested",
|
||||
|
|
@ -234,19 +226,21 @@ def test_parse_items_handles_nested_card_layout():
|
|||
|
||||
items = GenericTaskHandler._parse_items(definition, html, "https://example.com")
|
||||
|
||||
assert 2 == len(items), "Should extract 2 items"
|
||||
assert "https://example.com/poems/view/111" == items[0]["link"], "First item link should match"
|
||||
assert "First Poem" == items[0]["title"], "First item title should match"
|
||||
assert "Poet Alpha" == items[0]["poet"], "First item poet should match"
|
||||
assert "Category One" == items[0]["category"], "First item category should match"
|
||||
|
||||
assert "https://example.com/poems/view/222" == items[1]["link"], "Second item link should match"
|
||||
assert "Second Poem" == items[1]["title"], "Second item title should match"
|
||||
assert "Poet Beta" == items[1]["poet"], "Second item poet should match"
|
||||
assert "category" not in items[1], "Second item should not have category"
|
||||
assert len(items) == 2
|
||||
assert items[0] == {
|
||||
"link": "https://example.com/poems/view/111",
|
||||
"title": "First Poem",
|
||||
"poet": "Poet Alpha",
|
||||
"category": "Category One",
|
||||
}
|
||||
assert items[1] == {
|
||||
"link": "https://example.com/poems/view/222",
|
||||
"title": "Second Poem",
|
||||
"poet": "Poet Beta",
|
||||
}
|
||||
|
||||
|
||||
def test_parse_items_handles_json_container():
|
||||
def test_parse_items_json():
|
||||
definition = TaskDefinition(
|
||||
id=6,
|
||||
name="json",
|
||||
|
|
@ -287,14 +281,10 @@ def test_parse_items_handles_json_container():
|
|||
json_data=payload,
|
||||
)
|
||||
|
||||
assert 2 == len(items), "Should extract 2 items (third missing link)"
|
||||
assert "https://example.com/video/1" == items[0]["link"], "First item link should be absolute"
|
||||
assert "First" == items[0]["title"], "First item title should match"
|
||||
assert "1" == items[0]["id"], "First item id should match"
|
||||
|
||||
assert "https://example.com/video/2" == items[1]["link"], "Second item link should match"
|
||||
assert "Second" == items[1]["title"], "Second item title should match"
|
||||
assert "2" == items[1]["id"], "Second item id should match"
|
||||
assert items == [
|
||||
{"link": "https://example.com/video/1", "title": "First", "id": "1"},
|
||||
{"link": "https://example.com/video/2", "title": "Second", "id": "2"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -345,14 +335,14 @@ async def test_generic_task_handler_inspect(monkeypatch):
|
|||
task = HandleTask(id=1, name="Inspect", url="https://example.com/api")
|
||||
result: TaskResult | TaskFailure = await GenericTaskHandler.extract(task)
|
||||
|
||||
assert isinstance(result, TaskResult), "Result should be TaskResult"
|
||||
assert 1 == len(result.items), "Should have 1 item"
|
||||
assert isinstance(result, TaskResult)
|
||||
assert len(result.items) == 1
|
||||
item = result.items[0]
|
||||
assert "https://example.com/video/1" == item.url, "Item URL should match"
|
||||
assert "First" == item.title, "Item title should match"
|
||||
assert item.url == "https://example.com/video/1"
|
||||
assert item.title == "First"
|
||||
|
||||
|
||||
def test_parse_items_handles_json_top_level_list():
|
||||
def test_parse_items_json_list():
|
||||
definition = TaskDefinition(
|
||||
id=8,
|
||||
name="json-list",
|
||||
|
|
@ -389,8 +379,7 @@ def test_parse_items_handles_json_top_level_list():
|
|||
json_data=payload,
|
||||
)
|
||||
|
||||
assert 2 == len(items), "Should extract 2 items"
|
||||
assert "https://example.com/video/1" == items[0]["link"], "First item link should match"
|
||||
assert "First" == items[0]["title"], "First item title should match"
|
||||
assert "https://example.com/video/2" == items[1]["link"], "Second item link should match"
|
||||
assert "Second" == items[1]["title"], "Second item title should match"
|
||||
assert items == [
|
||||
{"link": "https://example.com/video/1", "title": "First"},
|
||||
{"link": "https://example.com/video/2", "title": "Second"},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ async def test_tver_handler_extract(monkeypatch):
|
|||
|
||||
|
||||
@pytest.mark.parametrize(("url", "should_match"), TverHandler.tests())
|
||||
def test_tver_handler_parse(url: str, should_match: bool):
|
||||
def test_parse(url: str, should_match: bool):
|
||||
"""Test tver URL parsing."""
|
||||
result = TverHandler.parse(url)
|
||||
if should_match:
|
||||
|
|
@ -153,7 +153,7 @@ def test_tver_handler_parse(url: str, should_match: bool):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tver_handler_can_handle():
|
||||
async def test_can_handle():
|
||||
"""Test tver handler can_handle method."""
|
||||
task_valid = HandleTask(id=1, name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||
task_invalid = HandleTask(id=2, name="Test", url="https://youtube.com/watch?v=123", preset="default")
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class TestProcessPoolConfiguration:
|
|||
assert kwargs == {"mp_context": context}
|
||||
get_context.assert_called_once_with("fork")
|
||||
|
||||
def test_uses_default_context_when_not_frozen_linux(self, monkeypatch):
|
||||
def test_default_context_linux(self, monkeypatch):
|
||||
monkeypatch.setattr("app.features.ytdlp.extractor.sys.platform", "linux")
|
||||
monkeypatch.delattr("app.features.ytdlp.extractor.sys.frozen", raising=False)
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ class TestProcessPoolConfiguration:
|
|||
assert _get_process_pool_kwargs() == {}
|
||||
get_context.assert_not_called()
|
||||
|
||||
def test_initializes_process_pool_with_context_kwargs(self, monkeypatch):
|
||||
def test_init_pool_kwargs(self, monkeypatch):
|
||||
context = object()
|
||||
monkeypatch.setattr("app.features.ytdlp.extractor._get_process_pool_kwargs", lambda: {"mp_context": context})
|
||||
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ class TestMiniFilter(unittest.TestCase):
|
|||
self._test("filesize>=1GiB", {"filesize": 2**30}, expected_result=True, test_name="filesize_1gib_positive")
|
||||
self._test("filesize>=1GiB", {"filesize": 1000000}, expected_result=False, test_name="filesize_1gib_negative")
|
||||
|
||||
def test_complex_duration_units_with_or_and_grouping(self):
|
||||
def test_duration_or_grouping(self):
|
||||
"""Test complex expressions with duration units, OR operations, and grouping. Skip yt-dlp due to known parsing bugs."""
|
||||
# Test grouping with duration units
|
||||
expr = "(filesize>1MB & duration<10m) || uploader='BBC'"
|
||||
|
|
@ -310,7 +310,7 @@ class TestMiniFilter(unittest.TestCase):
|
|||
self._test(expr, {"title": "a(b)c"}, expected_result=True, test_name="quoted_parentheses_match")
|
||||
self._test(expr, {"title": "abc"}, expected_result=False, test_name="quoted_parentheses_no_match")
|
||||
|
||||
def test_escaped_ampersand_inside_quoted_regex(self):
|
||||
def test_quoted_regex_ampersand(self):
|
||||
expr = r"description~='(?i)\bcats \& dogs\b'"
|
||||
|
||||
parser = MiniFilter(expr)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class TestArchiveProxy:
|
|||
assert p2.add("") is False
|
||||
|
||||
@patch("app.features.ytdlp.archiver.Archiver.get_instance")
|
||||
def test_contains_and_add_delegate_to_archiver(self, mock_get_instance) -> None:
|
||||
def test_delegates_to_archiver(self, mock_get_instance) -> None:
|
||||
arch = MagicMock()
|
||||
mock_get_instance.return_value = arch
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ class TestArchiveProxy:
|
|||
|
||||
|
||||
class TestYtDlpOptions:
|
||||
def test_options_structure_and_no_suppresshelp(self) -> None:
|
||||
def test_options_shape(self) -> None:
|
||||
opts = ytdlp_options()
|
||||
|
||||
assert isinstance(opts, list)
|
||||
|
|
@ -94,7 +94,7 @@ class TestYTDLP:
|
|||
return ytdlp
|
||||
|
||||
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
|
||||
def test_init_patches_download_archive_param(self, mock_super_init) -> None:
|
||||
def test_init_archive_param(self, mock_super_init) -> None:
|
||||
"""Test that __init__ removes download_archive before calling super, then restores it."""
|
||||
mock_super_init.return_value = None
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ class TestYTDLP:
|
|||
assert ytdlp.archive._file == "/tmp/archive.txt"
|
||||
|
||||
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
|
||||
def test_init_handles_no_download_archive(self, mock_super_init) -> None:
|
||||
def test_init_no_archive(self, mock_super_init) -> None:
|
||||
"""Test __init__ works correctly when download_archive is not in params."""
|
||||
mock_super_init.return_value = None
|
||||
|
||||
|
|
@ -139,7 +139,7 @@ class TestYTDLP:
|
|||
assert not ytdlp.archive
|
||||
|
||||
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
|
||||
def test_init_handles_none_params(self, mock_super_init) -> None:
|
||||
def test_init_none_params(self, mock_super_init) -> None:
|
||||
"""Test __init__ handles None params gracefully."""
|
||||
mock_super_init.return_value = None
|
||||
|
||||
|
|
@ -150,7 +150,7 @@ class TestYTDLP:
|
|||
assert not ytdlp.archive
|
||||
|
||||
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
|
||||
def test_init_patches_windows_popen_wait_once(self, mock_super_init) -> None:
|
||||
def test_init_patches_wait(self, mock_super_init) -> None:
|
||||
mock_super_init.return_value = None
|
||||
|
||||
class FakePopen:
|
||||
|
|
@ -163,7 +163,7 @@ class TestYTDLP:
|
|||
|
||||
assert getattr(FakePopen, "_ytptube_wait_patched", False) is True
|
||||
|
||||
def test_windows_wait_patch_uses_polling_for_blocking_wait(self) -> None:
|
||||
def test_wait_patch_polls(self) -> None:
|
||||
calls: list[float | None] = []
|
||||
|
||||
class FakePopen:
|
||||
|
|
@ -186,7 +186,7 @@ class TestYTDLP:
|
|||
assert result == 0
|
||||
assert calls == [0.1, 0.1, 0.1]
|
||||
|
||||
def test_windows_wait_patch_preserves_explicit_timeout(self) -> None:
|
||||
def test_wait_patch_timeout(self) -> None:
|
||||
calls: list[float | None] = []
|
||||
|
||||
class FakePopen:
|
||||
|
|
@ -205,7 +205,7 @@ class TestYTDLP:
|
|||
assert calls == [5]
|
||||
|
||||
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
|
||||
def test_delete_downloaded_files_skips_when_interrupted(self, mock_super_delete) -> None:
|
||||
def test_delete_interrupted(self, mock_super_delete) -> None:
|
||||
"""Test _delete_downloaded_files skips cleanup when _interrupted is True."""
|
||||
with patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
|
||||
ytdlp = YTDLP(params={})
|
||||
|
|
@ -224,7 +224,7 @@ class TestYTDLP:
|
|||
assert result is None
|
||||
|
||||
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
|
||||
def test_delete_downloaded_files_calls_super_when_not_interrupted(self, mock_super_delete) -> None:
|
||||
def test_delete_calls_super(self, mock_super_delete) -> None:
|
||||
"""Test _delete_downloaded_files calls super when not interrupted."""
|
||||
mock_super_delete.return_value = "cleanup_result"
|
||||
|
||||
|
|
@ -239,7 +239,7 @@ class TestYTDLP:
|
|||
# Should return super's result
|
||||
assert result == "cleanup_result"
|
||||
|
||||
def test_record_download_archive_does_nothing_without_download_archive_param(self) -> None:
|
||||
def test_record_archive_missing(self) -> None:
|
||||
"""Test record_download_archive returns early when download_archive is not set."""
|
||||
ytdlp = self._create_ytdlp(params={})
|
||||
ytdlp.archive = Mock()
|
||||
|
|
@ -249,7 +249,7 @@ class TestYTDLP:
|
|||
# Should not interact with archive
|
||||
ytdlp.archive.add.assert_not_called()
|
||||
|
||||
def test_record_download_archive_adds_archive_id(self) -> None:
|
||||
def test_record_archive_adds_id(self) -> None:
|
||||
"""Test record_download_archive adds the archive ID."""
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
|
||||
ytdlp.write_debug = Mock()
|
||||
|
|
@ -267,7 +267,7 @@ class TestYTDLP:
|
|||
ytdlp.archive.add.assert_called_once_with("youtube test123")
|
||||
ytdlp.write_debug.assert_called_with("Adding to archive: youtube test123")
|
||||
|
||||
def test_record_download_archive_handles_old_archive_ids(self) -> None:
|
||||
def test_record_archive_old_ids(self) -> None:
|
||||
"""Test record_download_archive adds _old_archive_ids when present."""
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
|
||||
ytdlp.write_debug = Mock()
|
||||
|
|
@ -294,7 +294,7 @@ class TestYTDLP:
|
|||
# Should not add duplicate (youtube new123 appears only once)
|
||||
assert calls.count("youtube new123") == 1
|
||||
|
||||
def test_record_download_archive_skips_empty_old_archive_ids(self) -> None:
|
||||
def test_record_archive_empty_old_ids(self) -> None:
|
||||
"""Test record_download_archive handles empty or invalid _old_archive_ids."""
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
|
||||
ytdlp.write_debug = Mock()
|
||||
|
|
@ -320,7 +320,7 @@ class TestYTDLP:
|
|||
ytdlp.record_download_archive(info_dict)
|
||||
assert ytdlp.archive.add.call_count == 1 # Only main ID
|
||||
|
||||
def test_record_download_archive_returns_early_on_empty_archive_id(self) -> None:
|
||||
def test_record_archive_empty_id(self) -> None:
|
||||
"""Test record_download_archive returns early when _make_archive_id returns empty."""
|
||||
ytdlp = self._create_ytdlp(params={"download_archive": "/tmp/archive.txt"})
|
||||
ytdlp.archive = Mock()
|
||||
|
|
@ -331,7 +331,7 @@ class TestYTDLP:
|
|||
# Should not add anything
|
||||
ytdlp.archive.add.assert_not_called()
|
||||
|
||||
def test_prepare_outtmpl_resolves_custom_callable(self) -> None:
|
||||
def test_outtmpl_callable(self) -> None:
|
||||
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
|
||||
|
||||
result = ytdlp.evaluate_outtmpl("%(ytp_random:8)s", {"title": "x"})
|
||||
|
|
@ -339,7 +339,7 @@ class TestYTDLP:
|
|||
assert len(result) == 8
|
||||
assert result.isalnum()
|
||||
|
||||
def test_prepare_outtmpl_reuses_same_callable_value_per_download(self) -> None:
|
||||
def test_outtmpl_reuses_value(self) -> None:
|
||||
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
|
||||
|
||||
result = ytdlp.evaluate_outtmpl("%(ytp_random:8)s/%(ytp_random:8)s", {"title": "x"})
|
||||
|
|
@ -347,7 +347,7 @@ class TestYTDLP:
|
|||
|
||||
assert first == second
|
||||
|
||||
def test_prepare_outtmpl_does_not_reuse_callable_value_across_calls(self) -> None:
|
||||
def test_outtmpl_new_value(self) -> None:
|
||||
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
|
||||
|
||||
first = ytdlp.evaluate_outtmpl("%(ytp_random:8)s", {"title": "x"})
|
||||
|
|
@ -355,7 +355,7 @@ class TestYTDLP:
|
|||
|
||||
assert first != second
|
||||
|
||||
def test_prepare_filename_reuses_custom_value_for_sidecars_of_same_entry(self) -> None:
|
||||
def test_prepare_filename_sidecars(self) -> None:
|
||||
ytdlp = YTDLP(
|
||||
params={
|
||||
"outtmpl": {
|
||||
|
|
@ -383,7 +383,7 @@ class TestYTDLP:
|
|||
assert default_base == subtitle_base
|
||||
assert default_base == infojson_base
|
||||
|
||||
def test_prepare_filename_resets_custom_value_for_different_info_dict_objects(self) -> None:
|
||||
def test_prepare_filename_resets(self) -> None:
|
||||
ytdlp = YTDLP(params={"outtmpl": {"default": "%(ytp_random:8)s.%(ext)s"}})
|
||||
|
||||
first = ytdlp.prepare_filename({"id": "one", "title": "One", "ext": "mp4"})
|
||||
|
|
@ -399,7 +399,7 @@ class TestYTDLP:
|
|||
("%(ytp_random:8)S", 8),
|
||||
],
|
||||
)
|
||||
def test_prepare_outtmpl_preserves_ytdlp_suffix_formatting(self, template: str, expected: int) -> None:
|
||||
def test_outtmpl_suffix(self, template: str, expected: int) -> None:
|
||||
ytdlp = YTDLP(
|
||||
params={
|
||||
"outtmpl": {"default": "%(title)s"},
|
||||
|
|
@ -411,7 +411,7 @@ class TestYTDLP:
|
|||
|
||||
assert len(result) == expected
|
||||
|
||||
def test_prepare_outtmpl_supports_digit_and_string_modes(self) -> None:
|
||||
def test_outtmpl_modes(self) -> None:
|
||||
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
|
||||
|
||||
digits = ytdlp.evaluate_outtmpl("%(ytp_random:6:d)s", {"title": "x"})
|
||||
|
|
@ -420,13 +420,13 @@ class TestYTDLP:
|
|||
assert digits.isdigit()
|
||||
assert letters.isalpha()
|
||||
|
||||
def test_prepare_outtmpl_rejects_unknown_callable(self) -> None:
|
||||
def test_outtmpl_unknown_callable(self) -> None:
|
||||
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported YTPTube output template callable"):
|
||||
ytdlp.prepare_outtmpl("%(ytp_unknown:8)s", {"title": "x"})
|
||||
|
||||
def test_prepare_outtmpl_rejects_invalid_random_length(self) -> None:
|
||||
def test_outtmpl_invalid_length(self) -> None:
|
||||
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
|
||||
|
||||
with pytest.raises(ValueError, match="ytp_random length must be an integer"):
|
||||
|
|
@ -434,7 +434,7 @@ class TestYTDLP:
|
|||
|
||||
|
||||
class TestOuttmpl:
|
||||
def test_rewrite_outtmpl_uses_shared_cache_per_call_group(self) -> None:
|
||||
def test_rewrite_outtmpl_cache(self) -> None:
|
||||
cache: dict[str, object] = {}
|
||||
template = "%(ytp_random:8)s/%(ytp_random:8)s.%(ext)s"
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class TestYTDLPOpts:
|
|||
assert opts._item_cli == []
|
||||
assert opts._preset_cli == ""
|
||||
|
||||
def test_get_instance_returns_reset_instance(self):
|
||||
def test_get_instance(self):
|
||||
"""Test that get_instance returns a reset YTDLPOpts instance."""
|
||||
with patch("app.features.ytdlp.ytdlp_opts.Config"):
|
||||
opts = YTDLPOpts.get_instance()
|
||||
|
|
@ -51,7 +51,7 @@ class TestYTDLPOpts:
|
|||
assert "--format best" in opts._item_cli
|
||||
mock_converter.assert_called_once_with(args="--format best", level=False)
|
||||
|
||||
def test_add_cli_with_invalid_args_raises_error(self):
|
||||
def test_add_cli_invalid(self):
|
||||
"""Test that invalid CLI arguments raise ValueError."""
|
||||
with (
|
||||
patch("app.features.ytdlp.ytdlp_opts.Config"),
|
||||
|
|
@ -63,7 +63,7 @@ class TestYTDLPOpts:
|
|||
with pytest.raises(ValueError, match="Invalid command options for yt-dlp were given"):
|
||||
opts.add_cli("--invalid-arg", from_user=True)
|
||||
|
||||
def test_add_cli_with_empty_args_returns_self(self):
|
||||
def test_add_cli_empty(self):
|
||||
"""Test that empty or invalid args return self without processing."""
|
||||
with patch("app.features.ytdlp.ytdlp_opts.Config"):
|
||||
opts = YTDLPOpts()
|
||||
|
|
@ -95,7 +95,7 @@ class TestYTDLPOpts:
|
|||
assert opts._item_opts["format"] == "best"
|
||||
assert opts._item_opts["quality"] == "720p"
|
||||
|
||||
def test_add_with_user_config_filters_bad_options(self):
|
||||
def test_add_filters_bad_options(self):
|
||||
"""Test that user config filters out dangerous options."""
|
||||
with patch("app.features.ytdlp.ytdlp_opts.Config"):
|
||||
opts = YTDLPOpts()
|
||||
|
|
@ -211,7 +211,7 @@ class TestYTDLPOpts:
|
|||
assert opts._preset_opts["cookiefile"] == expected_path
|
||||
mock_preset.get_cookies_file.assert_called_once_with(config=mock_config_instance)
|
||||
|
||||
def test_preset_with_invalid_cli_raises_error(self):
|
||||
def test_preset_invalid_cli(self):
|
||||
"""Test that preset with invalid CLI raises ValueError."""
|
||||
with (
|
||||
patch("app.features.ytdlp.ytdlp_opts.Config"),
|
||||
|
|
@ -289,7 +289,7 @@ class TestYTDLPOpts:
|
|||
expected_cli = "--quality 720p\n--format best"
|
||||
mock_converter.assert_called_once_with(args=expected_cli, level=True)
|
||||
|
||||
def test_get_all_handles_format_special_cases(self):
|
||||
def test_get_all_format_cases(self):
|
||||
"""Test get_all handles special format values correctly."""
|
||||
with (
|
||||
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
||||
|
|
@ -325,7 +325,7 @@ class TestYTDLPOpts:
|
|||
result = opts.get_all(keep=True)
|
||||
assert result["format"] == "best"
|
||||
|
||||
def test_get_all_with_invalid_cli_raises_error(self):
|
||||
def test_get_all_invalid_cli(self):
|
||||
"""Test get_all raises error for invalid CLI arguments."""
|
||||
with (
|
||||
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
||||
|
|
@ -578,7 +578,7 @@ class TestARGSMerger:
|
|||
assert "--output" in merger.args
|
||||
assert "--socket-timeout" in merger.args
|
||||
|
||||
def test_add_filters_complex_commented_extractor_args(self):
|
||||
def test_add_complex_comments(self):
|
||||
"""Test filtering of complex real-world commented extractor-args."""
|
||||
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
||||
|
||||
|
|
@ -631,7 +631,7 @@ class TestARGSMerger:
|
|||
assert "--format" in merger.args
|
||||
assert "bestvideo[height<=1080]+bestaudio/best" in merger.args
|
||||
|
||||
def test_add_empty_string_returns_self(self):
|
||||
def test_add_empty(self):
|
||||
"""Test that adding empty string returns self without modifying args."""
|
||||
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
||||
|
||||
|
|
@ -641,7 +641,7 @@ class TestARGSMerger:
|
|||
assert result is merger
|
||||
assert merger.args == []
|
||||
|
||||
def test_add_short_string_returns_self(self):
|
||||
def test_add_short(self):
|
||||
"""Test that adding short string (len < 2) returns self without modifying args."""
|
||||
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
||||
|
||||
|
|
@ -651,7 +651,7 @@ class TestARGSMerger:
|
|||
assert result is merger
|
||||
assert merger.args == []
|
||||
|
||||
def test_add_non_string_returns_self(self):
|
||||
def test_add_non_string(self):
|
||||
"""Test that adding non-string returns self without modifying args."""
|
||||
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
||||
|
||||
|
|
@ -769,7 +769,7 @@ class TestYTDLPCli:
|
|||
assert cli.item is item
|
||||
assert cli._config is mock_config_instance
|
||||
|
||||
def test_constructor_with_invalid_type_raises_error(self):
|
||||
def test_constructor_invalid_item(self):
|
||||
"""Test YTDLPCli constructor raises error with non-Item type."""
|
||||
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
||||
|
||||
|
|
|
|||
|
|
@ -48,14 +48,14 @@ class TestLogWrapper:
|
|||
with pytest.raises(TypeError, match=r"Target must be a logging\.Logger instance or a callable"):
|
||||
lw.add_target(123) # type: ignore[arg-type]
|
||||
|
||||
def test_add_target_name_inference_and_custom(self) -> None:
|
||||
def test_add_target_names(self) -> None:
|
||||
lw = LogWrapper()
|
||||
logger, _ = make_logger("one")
|
||||
|
||||
# Name inferred from logger
|
||||
lw.add_target(logger)
|
||||
assert lw.targets[-1].name == "one"
|
||||
assert lw.has_targets() is True
|
||||
assert lw.has_targets()
|
||||
|
||||
# Name inferred from callable
|
||||
def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None: # noqa: ARG001
|
||||
|
|
@ -129,7 +129,7 @@ class TestExtractYtdlpLogs:
|
|||
logs = ["INFO: Downloading", "ERROR: Failed", "WARNING: Deprecated"]
|
||||
filters = [re.compile(r"ERROR")]
|
||||
result = extract_ytdlp_logs(logs, filters)
|
||||
assert len(result) >= 0 # Should filter based on patterns
|
||||
assert result == ["ERROR: Failed"]
|
||||
|
||||
def test_extract_ytdlp_logs_empty(self):
|
||||
"""Test with empty logs."""
|
||||
|
|
@ -146,8 +146,8 @@ class TestYtdlpReject:
|
|||
yt_params = {}
|
||||
|
||||
passed, message = ytdlp_reject(entry, yt_params)
|
||||
assert isinstance(passed, bool)
|
||||
assert isinstance(message, str)
|
||||
assert passed is True
|
||||
assert message == ""
|
||||
|
||||
def test_ytdlp_reject_with_filters(self):
|
||||
"""Test rejection with filters."""
|
||||
|
|
@ -158,8 +158,9 @@ class TestYtdlpReject:
|
|||
yt_params["daterange"].__contains__ = MagicMock(return_value=False)
|
||||
|
||||
passed, message = ytdlp_reject(entry, yt_params)
|
||||
assert isinstance(passed, bool)
|
||||
assert isinstance(message, str)
|
||||
assert passed is False
|
||||
assert "20230101" in message
|
||||
assert "not in range" in message
|
||||
|
||||
|
||||
class TestParseOuttmpl:
|
||||
|
|
@ -256,7 +257,7 @@ class TestParseOuttmpl:
|
|||
|
||||
assert result == "Test Channel/Best Videos/005 - Amazing Content [abc123xyz].mp4"
|
||||
|
||||
def test_parse_outtmpl_with_special_characters(self):
|
||||
def test_parse_outtmpl_special_chars(self):
|
||||
"""Test template parsing handles special characters in values."""
|
||||
|
||||
template = "%(title)s.%(ext)s"
|
||||
|
|
@ -284,7 +285,7 @@ class TestParseOuttmpl:
|
|||
|
||||
assert result == "My Playlist/Video Title.webm"
|
||||
|
||||
def test_parse_outtmpl_with_restrict_filename(self):
|
||||
def test_parse_outtmpl_restrict(self):
|
||||
"""Test template parsing with restrict_filename parameter."""
|
||||
|
||||
template = "%(uploader)s/%(title)s.%(ext)s"
|
||||
|
|
@ -302,19 +303,19 @@ class TestParseOuttmpl:
|
|||
|
||||
|
||||
class TestGetThumbnail:
|
||||
def test_returns_none_for_empty_list(self):
|
||||
def test_empty_list(self):
|
||||
"""Test that None is returned for an empty thumbnail list."""
|
||||
|
||||
assert get_thumbnail([]) is None
|
||||
|
||||
def test_returns_none_for_non_list(self):
|
||||
def test_non_list(self):
|
||||
"""Test that None is returned for non-list input."""
|
||||
|
||||
assert get_thumbnail(None) is None
|
||||
assert get_thumbnail("not a list") is None
|
||||
assert get_thumbnail({"not": "list"}) is None
|
||||
|
||||
def test_returns_highest_preference_thumbnail(self):
|
||||
def test_thumbnail_preference(self):
|
||||
"""Test that the thumbnail with highest preference is returned."""
|
||||
|
||||
thumbnails = [
|
||||
|
|
@ -326,7 +327,7 @@ class TestGetThumbnail:
|
|||
result = get_thumbnail(thumbnails)
|
||||
assert result == {"url": "high.jpg", "preference": 10, "width": 200, "height": 200}
|
||||
|
||||
def test_returns_highest_width_when_preference_equal(self):
|
||||
def test_thumbnail_width(self):
|
||||
"""Test that the thumbnail with highest width is returned when preference is equal."""
|
||||
|
||||
thumbnails = [
|
||||
|
|
@ -338,7 +339,7 @@ class TestGetThumbnail:
|
|||
result = get_thumbnail(thumbnails)
|
||||
assert result == {"url": "large.jpg", "preference": 1, "width": 200, "height": 200}
|
||||
|
||||
def test_handles_missing_attributes(self):
|
||||
def test_missing_attrs(self):
|
||||
"""Test that thumbnails with missing attributes are handled correctly."""
|
||||
|
||||
thumbnails = [
|
||||
|
|
@ -349,7 +350,7 @@ class TestGetThumbnail:
|
|||
result = get_thumbnail(thumbnails)
|
||||
assert result["url"] == "with_pref.jpg"
|
||||
|
||||
def test_returns_first_when_all_equal(self):
|
||||
def test_all_equal(self):
|
||||
"""Test that any thumbnail is returned when all attributes are equal."""
|
||||
|
||||
thumbnails = [
|
||||
|
|
@ -357,17 +358,15 @@ class TestGetThumbnail:
|
|||
{"url": "second.jpg"},
|
||||
]
|
||||
|
||||
result = get_thumbnail(thumbnails)
|
||||
assert result is not None
|
||||
assert result["url"] in ["first.jpg", "second.jpg"]
|
||||
assert get_thumbnail(thumbnails) == {"url": "second.jpg"}
|
||||
|
||||
|
||||
class TestGetExtras:
|
||||
def test_returns_empty_dict_for_none(self):
|
||||
def test_none(self):
|
||||
"""Test that empty dict is returned for None input."""
|
||||
assert get_extras(None) == {}
|
||||
|
||||
def test_returns_empty_dict_for_non_dict(self):
|
||||
def test_non_dict(self):
|
||||
"""Test that empty dict is returned for non-dict input."""
|
||||
assert get_extras("not a dict") == {}
|
||||
assert get_extras([]) == {}
|
||||
|
|
@ -409,7 +408,7 @@ class TestGetExtras:
|
|||
assert result["playlist_uploader"] == "Playlist Owner"
|
||||
assert result["playlist_uploader_id"] == "owner123"
|
||||
|
||||
def test_handles_release_timestamp(self):
|
||||
def test_release_timestamp(self):
|
||||
"""Test handling of release_timestamp for upcoming content."""
|
||||
|
||||
entry = {
|
||||
|
|
@ -421,7 +420,7 @@ class TestGetExtras:
|
|||
assert "release_in" in result
|
||||
assert result["release_in"] == "Fri, 13 Feb 2009 23:31:30 GMT"
|
||||
|
||||
def test_handles_upcoming_live_stream(self):
|
||||
def test_upcoming_live(self):
|
||||
"""Test handling of upcoming live stream."""
|
||||
|
||||
entry = {
|
||||
|
|
@ -434,7 +433,7 @@ class TestGetExtras:
|
|||
assert result["is_live"] == 1234567890
|
||||
assert "release_in" in result
|
||||
|
||||
def test_handles_premiere_flag(self):
|
||||
def test_premiere_flag(self):
|
||||
"""Test handling of is_premiere flag."""
|
||||
|
||||
entry = {
|
||||
|
|
@ -481,7 +480,7 @@ class TestGetStaticYtdlp:
|
|||
|
||||
_DATA.YTDLP_INFO_CLS = None
|
||||
|
||||
def test_get_static_ytdlp_returns_instance(self):
|
||||
def test_instance(self):
|
||||
"""Test that get_static_ytdlp returns a YTDLP instance."""
|
||||
from app.features.ytdlp.ytdlp import YTDLP
|
||||
|
||||
|
|
@ -491,7 +490,7 @@ class TestGetStaticYtdlp:
|
|||
assert instance is not None
|
||||
assert isinstance(instance, YTDLP)
|
||||
|
||||
def test_get_static_ytdlp_returns_same_instance(self):
|
||||
def test_get_static_ytdlp_same(self):
|
||||
"""Test that get_static_ytdlp returns the same cached instance."""
|
||||
|
||||
instance1 = get_ytdlp()
|
||||
|
|
@ -508,7 +507,7 @@ class TestGetStaticYtdlp:
|
|||
assert instance1 is not instance2
|
||||
assert instance2 is not None
|
||||
|
||||
def test_get_static_ytdlp_has_correct_params(self):
|
||||
def test_get_static_ytdlp_params(self):
|
||||
"""Test that get_static_ytdlp initializes with correct parameters."""
|
||||
|
||||
instance = get_ytdlp()
|
||||
|
|
@ -560,22 +559,22 @@ class TestArchiveFunctions:
|
|||
|
||||
def test_archive_add_and_read(self):
|
||||
"""Test adding and reading archive entries."""
|
||||
ids = ["id1", "id2", "id3"]
|
||||
ids = ["youtube id1", "youtube id2", "youtube id3"]
|
||||
|
||||
# Add entries - just test it returns a boolean
|
||||
result = archive_add(self.archive_file, ids)
|
||||
assert isinstance(result, bool)
|
||||
assert result is True
|
||||
|
||||
# Read entries - just test it returns a list
|
||||
read_ids = archive_read(self.archive_file)
|
||||
assert isinstance(read_ids, list)
|
||||
assert set(read_ids) == set(ids)
|
||||
|
||||
def test_archive_delete(self):
|
||||
"""Test deleting archive entries."""
|
||||
# Delete some entries - just test it returns a boolean
|
||||
delete_ids = ["id2"]
|
||||
archive_add(self.archive_file, ["youtube id1", "youtube id2", "youtube id3"])
|
||||
|
||||
delete_ids = ["youtube id2"]
|
||||
result = archive_delete(self.archive_file, delete_ids)
|
||||
assert isinstance(result, bool)
|
||||
assert result is True
|
||||
assert set(archive_read(self.archive_file)) == {"youtube id1", "youtube id3"}
|
||||
|
||||
def test_archive_read_nonexistent(self):
|
||||
"""Test reading from non-existent archive."""
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class TestCache:
|
|||
time.sleep(0.2)
|
||||
assert self.cache.get("temp_key") is None
|
||||
|
||||
def test_set_without_ttl(self):
|
||||
def test_set_no_ttl(self):
|
||||
"""Test setting values without TTL (permanent)."""
|
||||
self.cache.set("permanent_key", "permanent_value")
|
||||
assert self.cache.get("permanent_key") == "permanent_value"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ def reset_conditions_singleton():
|
|||
|
||||
class TestConditionIgnoreMatching:
|
||||
@pytest.mark.asyncio
|
||||
async def test_match_skips_condition_by_name_and_returns_next_match(self) -> None:
|
||||
async def test_match_skip_name(self) -> None:
|
||||
first = SimpleNamespace(id=1, name="Primary", enabled=True, filter="duration > 0", priority=20)
|
||||
second = SimpleNamespace(id=2, name="Fallback", enabled=True, filter="duration > 0", priority=10)
|
||||
|
||||
|
|
@ -30,7 +30,7 @@ class TestConditionIgnoreMatching:
|
|||
assert matched is second
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_match_skips_condition_by_stringified_id_and_returns_next_match(self) -> None:
|
||||
async def test_match_skip_id(self) -> None:
|
||||
first = SimpleNamespace(id=123, name="Primary", enabled=True, filter="duration > 0", priority=20)
|
||||
second = SimpleNamespace(id=124, name="Fallback", enabled=True, filter="duration > 0", priority=10)
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ class TestConditionIgnoreMatching:
|
|||
assert matched is second
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_match_coerces_numeric_ignore_values_to_strings(self) -> None:
|
||||
async def test_match_coerce_ids(self) -> None:
|
||||
first = SimpleNamespace(id=123, name="Primary", enabled=True, filter="duration > 0", priority=20)
|
||||
second = SimpleNamespace(id=124, name="Fallback", enabled=True, filter="duration > 0", priority=10)
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ class TestConditionIgnoreMatching:
|
|||
assert matched is second
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_match_returns_none_when_ignore_all_wildcard_is_present(self) -> None:
|
||||
async def test_match_wildcard(self) -> None:
|
||||
first = SimpleNamespace(id=1, name="Primary", enabled=True, filter="duration > 0", priority=20)
|
||||
|
||||
service = Conditions.get_instance()
|
||||
|
|
@ -67,7 +67,7 @@ class TestConditionIgnoreMatching:
|
|||
|
||||
class TestConditionIgnorePropagation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_item_adder_passes_ignore_conditions_to_matcher(self) -> None:
|
||||
async def test_add_passes_ignore(self) -> None:
|
||||
queue = Mock()
|
||||
queue.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False)
|
||||
queue._notify = Mock()
|
||||
|
|
@ -102,7 +102,7 @@ class TestConditionIgnorePropagation:
|
|||
assert result == {"status": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_item_adder_coerces_numeric_ignore_conditions_to_strings(self) -> None:
|
||||
async def test_add_coerces_ignore(self) -> None:
|
||||
queue = Mock()
|
||||
queue.config = Mock(temp_path="/tmp", ignore_archived_items=False, ytdlp_debug=False)
|
||||
queue._notify = Mock()
|
||||
|
|
@ -133,7 +133,7 @@ class TestConditionIgnorePropagation:
|
|||
matcher.match.assert_awaited_once_with(info=entry, ignore_conditions=["123", "Primary", "*"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_playlist_preserves_only_parent_ignore_conditions(self) -> None:
|
||||
async def test_playlist_keeps_parent_ignore(self) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeItem:
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ class TestDataStore:
|
|||
assert ok is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_by_status_drops_matching_cached_items(self) -> None:
|
||||
async def test_bulk_delete_status_cache(self) -> None:
|
||||
connection = Mock()
|
||||
connection.bulk_delete_by_status = AsyncMock(return_value=2)
|
||||
store = DataStore(StoreType.HISTORY, connection)
|
||||
|
|
@ -243,7 +243,7 @@ class TestDataStore:
|
|||
assert pending._id in store._dict
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_item_returns_none_when_no_kwargs(self) -> None:
|
||||
async def test_get_item_no_filters(self) -> None:
|
||||
"""Test that get_item returns None when no kwargs provided."""
|
||||
db = await make_db()
|
||||
store = DataStore(StoreType.QUEUE, db)
|
||||
|
|
@ -319,7 +319,7 @@ class TestDataStore:
|
|||
await db.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_item_returns_none_when_no_match(self) -> None:
|
||||
async def test_get_item_miss(self) -> None:
|
||||
"""Test that get_item returns None when no attributes match."""
|
||||
db = await make_db()
|
||||
store = DataStore(StoreType.QUEUE, db)
|
||||
|
|
@ -366,7 +366,7 @@ class TestDataStore:
|
|||
await db.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_item_returns_first_match(self) -> None:
|
||||
async def test_get_item_first_match(self) -> None:
|
||||
"""Test that get_item returns the first matching item when multiple match."""
|
||||
db = await make_db()
|
||||
store = DataStore(StoreType.QUEUE, db)
|
||||
|
|
@ -689,7 +689,7 @@ class TestDataStore:
|
|||
await db.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_downloads_with_no_eligible_downloads(self) -> None:
|
||||
async def test_has_downloads_ineligible(self) -> None:
|
||||
"""Test has_downloads returns False when no downloads are eligible."""
|
||||
db = await make_db()
|
||||
store = DataStore(StoreType.QUEUE, db)
|
||||
|
|
@ -708,7 +708,7 @@ class TestDataStore:
|
|||
await db.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_next_download_returns_none_when_empty(self) -> None:
|
||||
async def test_next_download_empty(self) -> None:
|
||||
"""Test get_next_download returns None when no eligible downloads."""
|
||||
db = await make_db()
|
||||
store = DataStore(StoreType.QUEUE, db)
|
||||
|
|
@ -740,7 +740,7 @@ class TestDataStore:
|
|||
await db.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_store_item_removes_datetime_field(self) -> None:
|
||||
async def test_update_item_drops_dt(self) -> None:
|
||||
"""Test that _update_store_item removes datetime field before storage."""
|
||||
db = await make_db()
|
||||
store = DataStore(StoreType.QUEUE, db)
|
||||
|
|
@ -760,7 +760,7 @@ class TestDataStore:
|
|||
await db.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_store_item_removes_live_in_when_finished(self) -> None:
|
||||
async def test_update_item_drops_live_in(self) -> None:
|
||||
"""Test that _update_store_item removes live_in field when status is finished."""
|
||||
store = await make_store_async(StoreType.QUEUE)
|
||||
conn = store._connection
|
||||
|
|
@ -781,7 +781,7 @@ class TestDataStore:
|
|||
await store._connection.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_store_item_keeps_live_in_when_not_finished(self) -> None:
|
||||
async def test_update_item_keeps_live_in(self) -> None:
|
||||
"""Test that _update_store_item keeps live_in field when status is not finished."""
|
||||
store = await make_store_async(StoreType.QUEUE)
|
||||
conn = store._connection
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ class TestDownloadFlow:
|
|||
assert queue.items[0]["download_skipped"] is True
|
||||
assert queue.items[1]["download_skipped"] is True
|
||||
|
||||
def test_download_resets_sigint_handler_in_worker(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_download_resets_sigint(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Cfg:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
|
|
@ -415,9 +415,7 @@ class TestDownloadFlow:
|
|||
assert ydl._interrupted is True
|
||||
ydl.to_screen.assert_called_once_with("[info] Interrupt received, exiting cleanly...")
|
||||
|
||||
def test_download_prefers_real_playlist_extras_over_placeholder_preinfo(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_playlist_extras(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Cfg:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
|
|
@ -606,9 +604,7 @@ class TestDownloadFlow:
|
|||
assert download.info.filename == "video.mp4", "Final filename should be set from status update"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_cancelled_download_drains_final_status_updates(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
async def test_live_cancel_drains_final(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
class Cfg:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
|
|
@ -711,7 +707,7 @@ class TestDownloadFlow:
|
|||
assert download.info.filename == "live.mp4", "Finalized live filename should be preserved"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_cancelled_download_skips_live_drain_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_regular_cancel_skips_drain(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Cfg:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
|
|
@ -785,7 +781,7 @@ class TestDownloadSpawnPickling:
|
|||
def setup_method(self):
|
||||
EventBus._reset_singleton()
|
||||
|
||||
def test_spawn_pickling_ignores_local_event_listener(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
def test_spawn_pickling_ignores_listener(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
class Cfg:
|
||||
debug = False
|
||||
ytdlp_debug = False
|
||||
|
|
@ -835,7 +831,7 @@ class TestDownloadSpawnPickling:
|
|||
|
||||
|
||||
class TestTempManager:
|
||||
def test_create_temp_path_when_disabled(self) -> None:
|
||||
def test_create_temp_path_disabled(self) -> None:
|
||||
info = make_item()
|
||||
logger = logging.getLogger("test")
|
||||
tm = TempManager(info, "/tmp", temp_disabled=True, temp_keep=False, logger=logger)
|
||||
|
|
@ -844,7 +840,7 @@ class TestTempManager:
|
|||
assert result is None, "Should return None when temp_disabled is True"
|
||||
assert tm.temp_path is None, "temp_path should remain None when disabled"
|
||||
|
||||
def test_create_temp_path_when_no_temp_dir(self) -> None:
|
||||
def test_create_temp_path_no_dir(self) -> None:
|
||||
info = make_item()
|
||||
logger = logging.getLogger("test")
|
||||
tm = TempManager(info, None, temp_disabled=False, temp_keep=False, logger=logger)
|
||||
|
|
@ -874,7 +870,7 @@ class TestTempManager:
|
|||
path2 = tm2.create_temp_path()
|
||||
assert path1 == path2, "Same download ID should produce same temp path"
|
||||
|
||||
def test_delete_temp_when_disabled(self, tmp_path: Path) -> None:
|
||||
def test_delete_temp_disabled(self, tmp_path: Path) -> None:
|
||||
info = make_item()
|
||||
logger = logging.getLogger("test")
|
||||
tm = TempManager(info, str(tmp_path), temp_disabled=True, temp_keep=False, logger=logger)
|
||||
|
|
@ -884,7 +880,7 @@ class TestTempManager:
|
|||
tm.delete_temp()
|
||||
assert tm.temp_path.exists(), "Should not delete when temp_disabled is True"
|
||||
|
||||
def test_delete_temp_when_temp_keep_enabled(self, tmp_path: Path) -> None:
|
||||
def test_delete_temp_keep(self, tmp_path: Path) -> None:
|
||||
info = make_item()
|
||||
logger = logging.getLogger("test")
|
||||
tm = TempManager(info, str(tmp_path), temp_disabled=False, temp_keep=True, logger=logger)
|
||||
|
|
@ -894,12 +890,13 @@ class TestTempManager:
|
|||
tm.delete_temp()
|
||||
assert tm.temp_path.exists(), "Should not delete when temp_keep is True"
|
||||
|
||||
def test_delete_temp_when_no_temp_path(self) -> None:
|
||||
def test_delete_temp_no_path(self) -> None:
|
||||
info = make_item()
|
||||
logger = logging.getLogger("test")
|
||||
tm = TempManager(info, "/tmp", temp_disabled=False, temp_keep=False, logger=logger)
|
||||
|
||||
tm.delete_temp()
|
||||
assert tm.temp_path is None, "temp_path should stay unset"
|
||||
|
||||
def test_delete_temp_keeps_partial_download(self, tmp_path: Path) -> None:
|
||||
info = make_item()
|
||||
|
|
@ -964,7 +961,7 @@ class TestProcessManager:
|
|||
assert pm.cancel_event.is_set() is False, "Should clear stale cancel events before starting"
|
||||
assert "download-test-id" == proc.name, "Process name should include download ID"
|
||||
|
||||
def test_started_returns_true_when_process_created(self) -> None:
|
||||
def test_started(self) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
pm = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
|
||||
|
|
@ -973,13 +970,13 @@ class TestProcessManager:
|
|||
pm.create_process(lambda: None)
|
||||
assert pm.started() is True, "Should return True after process created"
|
||||
|
||||
def test_running_returns_false_when_no_process(self) -> None:
|
||||
def test_running_no_process(self) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
pm = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
|
||||
assert pm.running() is False, "Should return False when no process"
|
||||
|
||||
def test_is_cancelled_returns_false_by_default(self) -> None:
|
||||
def test_is_cancelled_default(self) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
pm = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
|
||||
|
|
@ -994,7 +991,7 @@ class TestProcessManager:
|
|||
result = pm.cancel()
|
||||
assert pm.is_cancelled() is True, "Should mark as cancelled"
|
||||
|
||||
def test_cancel_returns_false_when_not_started(self) -> None:
|
||||
def test_cancel_not_started(self) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
pm = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
|
||||
|
|
@ -1002,7 +999,7 @@ class TestProcessManager:
|
|||
assert result is False, "Should return False when process not started"
|
||||
assert pm.is_cancelled() is False, "Should not mark as cancelled when not started"
|
||||
|
||||
def test_kill_returns_false_when_not_running(self) -> None:
|
||||
def test_kill_not_running(self) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
pm = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
|
||||
|
|
@ -1025,7 +1022,7 @@ class TestProcessManager:
|
|||
mock_kill.assert_called_once_with(12345, signal.SIGUSR1)
|
||||
assert result is True, "Should return True when process killed successfully"
|
||||
|
||||
def test_kill_uses_live_cancel_event_and_longer_graceful_timeout(self) -> None:
|
||||
def test_kill_live_uses_event(self) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
pm_live = ProcessManager("test-id", is_live=True, logger=logger)
|
||||
pm_regular = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
|
|
@ -1065,7 +1062,7 @@ class TestProcessManager:
|
|||
mock_wait.assert_called_once_with(pm_regular.proc, 5)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_returns_false_when_not_started(self) -> None:
|
||||
async def test_close_not_started(self) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
pm = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
|
||||
|
|
@ -1073,7 +1070,7 @@ class TestProcessManager:
|
|||
assert result is False, "Should return False when process not started"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_returns_false_when_cancel_in_progress(self) -> None:
|
||||
async def test_close_during_cancel(self) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
pm = ProcessManager("test-id", is_live=False, logger=logger)
|
||||
pm.proc = Mock()
|
||||
|
|
@ -1118,7 +1115,7 @@ class TestStatusTracker:
|
|||
assert st.final_update is False, "Should initialize final_update as False"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_status_update_ignores_invalid_id(self, mock_config: dict) -> None:
|
||||
async def test_status_ignores_bad_id(self, mock_config: dict) -> None:
|
||||
st = StatusTracker(**mock_config)
|
||||
status = {"id": "wrong-id", "status": "downloading"}
|
||||
|
||||
|
|
@ -1126,7 +1123,7 @@ class TestStatusTracker:
|
|||
assert st.info.status != "downloading", "Should not update status for wrong ID"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_status_update_ignores_short_status(self, mock_config: dict) -> None:
|
||||
async def test_status_ignores_short(self, mock_config: dict) -> None:
|
||||
st = StatusTracker(**mock_config)
|
||||
status = {"id": "test-id"}
|
||||
|
||||
|
|
@ -1141,7 +1138,7 @@ class TestStatusTracker:
|
|||
assert st.info.status == "downloading", "Should update info status"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_status_update_sets_download_skipped(self, mock_config: dict) -> None:
|
||||
async def test_status_sets_skipped(self, mock_config: dict) -> None:
|
||||
st = StatusTracker(**mock_config)
|
||||
status = {"id": "test-id", "status": "downloading", "download_skipped": True}
|
||||
|
||||
|
|
@ -1157,7 +1154,7 @@ class TestStatusTracker:
|
|||
assert st.tmpfilename == "/tmp/file.part", "Should update tmpfilename"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_status_update_calculates_percent(self, mock_config: dict) -> None:
|
||||
async def test_status_sets_percent(self, mock_config: dict) -> None:
|
||||
st = StatusTracker(**mock_config)
|
||||
status = {
|
||||
"id": "test-id",
|
||||
|
|
@ -1172,7 +1169,7 @@ class TestStatusTracker:
|
|||
assert st.info.percent == 50.0, "Should calculate percent correctly"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_status_update_uses_estimated_total(self, mock_config: dict) -> None:
|
||||
async def test_status_uses_estimate(self, mock_config: dict) -> None:
|
||||
st = StatusTracker(**mock_config)
|
||||
status = {
|
||||
"id": "test-id",
|
||||
|
|
@ -1186,7 +1183,7 @@ class TestStatusTracker:
|
|||
assert st.info.percent == 30.0, "Should calculate percent from estimate"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_status_update_handles_zero_division(self, mock_config: dict) -> None:
|
||||
async def test_status_percent(self, mock_config: dict) -> None:
|
||||
st = StatusTracker(**mock_config)
|
||||
status = {
|
||||
"id": "test-id",
|
||||
|
|
@ -1199,7 +1196,7 @@ class TestStatusTracker:
|
|||
assert st.info.percent == 50.0, "Should calculate percent correctly with valid total"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_status_update_sets_speed_and_eta(self, mock_config: dict) -> None:
|
||||
async def test_status_sets_speed_eta(self, mock_config: dict) -> None:
|
||||
st = StatusTracker(**mock_config)
|
||||
status = {"id": "test-id", "status": "downloading", "speed": 1024000, "eta": 60}
|
||||
|
||||
|
|
@ -1258,7 +1255,7 @@ class TestStatusTracker:
|
|||
assert st.final_update is True, "Should stop draining after final update"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_queue_handles_errors_gracefully(self, mock_config: dict) -> None:
|
||||
async def test_drain_queue_skips_invalid(self, mock_config: dict) -> None:
|
||||
queue = DummyQueue()
|
||||
queue.put({"id": "test-id", "status": "downloading"})
|
||||
queue.put(None)
|
||||
|
|
@ -1267,8 +1264,9 @@ class TestStatusTracker:
|
|||
st = StatusTracker(**config)
|
||||
|
||||
await st.drain_queue(max_iterations=5)
|
||||
assert st.info.status == "downloading", "valid updates should still be processed"
|
||||
|
||||
def test_cancel_update_task_cancels_running_task(self, mock_config: dict) -> None:
|
||||
def test_cancel_update_task(self, mock_config: dict) -> None:
|
||||
st = StatusTracker(**mock_config)
|
||||
st.update_task = Mock()
|
||||
st.update_task.done = Mock(return_value=False)
|
||||
|
|
@ -1277,10 +1275,11 @@ class TestStatusTracker:
|
|||
st.cancel_update_task()
|
||||
st.update_task.cancel.assert_called_once()
|
||||
|
||||
def test_cancel_update_task_handles_no_task(self, mock_config: dict) -> None:
|
||||
def test_cancel_update_task_noop(self, mock_config: dict) -> None:
|
||||
st = StatusTracker(**mock_config)
|
||||
|
||||
st.cancel_update_task()
|
||||
assert st.update_task is None, "missing tasks should be ignored"
|
||||
|
||||
def test_put_terminator_adds_to_queue(self, mock_config: dict) -> None:
|
||||
queue = DummyQueue()
|
||||
|
|
@ -1316,7 +1315,7 @@ class TestQueueManager:
|
|||
assert status[item.info._id] == "ok", "Running cancel should still report success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_running_regular_item_closes_immediately(self) -> None:
|
||||
async def test_cancel_regular_closes(self) -> None:
|
||||
queue_manager = object.__new__(DownloadQueue)
|
||||
queue_manager.queue = Mock()
|
||||
queue_manager.done = Mock()
|
||||
|
|
@ -1338,7 +1337,7 @@ class TestQueueManager:
|
|||
assert status[item.info._id] == "ok", "Regular running cancel should still report success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_flushes_history_deletes_before_returning(self) -> None:
|
||||
async def test_clear_flushes_history(self) -> None:
|
||||
queue_manager = object.__new__(DownloadQueue)
|
||||
queue_manager.config = Mock(remove_files=False, download_path="/tmp")
|
||||
queue_manager._notify = Mock()
|
||||
|
|
@ -1364,7 +1363,7 @@ class TestQueueManager:
|
|||
assert status[item.info._id] == "ok", "Clear should still report success after flushing deletes"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_bulk_uses_bulk_delete_and_aggregate_notification(self) -> None:
|
||||
async def test_clear_bulk_notifies(self) -> None:
|
||||
queue_manager = object.__new__(DownloadQueue)
|
||||
queue_manager.config = Mock(remove_files=False, download_path="/tmp")
|
||||
queue_manager._notify = Mock()
|
||||
|
|
@ -1394,7 +1393,7 @@ class TestQueueManager:
|
|||
assert queue_manager._notify.emit.call_args.kwargs["data"] == {"ids": ["done-id-1", "done-id-2"], "count": 2}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_by_status_uses_status_fetch_before_bulk_delete(self) -> None:
|
||||
async def test_clear_status_fetches(self) -> None:
|
||||
queue_manager = object.__new__(DownloadQueue)
|
||||
queue_manager.config = Mock(remove_files=False, download_path="/tmp")
|
||||
queue_manager._notify = Mock()
|
||||
|
|
@ -1414,7 +1413,7 @@ class TestQueueManager:
|
|||
assert queue_manager._notify.emit.call_args.kwargs["data"] == {"count": 1, "status": "finished"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_by_status_with_file_removal_fetches_matching_items(self) -> None:
|
||||
async def test_clear_status_files_fetch(self) -> None:
|
||||
queue_manager = object.__new__(DownloadQueue)
|
||||
queue_manager.config = Mock(remove_files=True, download_path="/tmp")
|
||||
queue_manager._notify = Mock()
|
||||
|
|
@ -1438,7 +1437,7 @@ class TestQueueManager:
|
|||
|
||||
class TestPoolManager:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_entry_with_final_file_stays_finished(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def test_cancelled_file_stays_finished(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
emitted_events: list[str] = []
|
||||
|
||||
class EB:
|
||||
|
|
@ -1483,9 +1482,7 @@ class TestPoolManager:
|
|||
done_store.put.assert_awaited_once_with(entry)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_regular_entry_with_final_file_stays_cancelled(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def test_cancelled_regular_file_stays(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
emitted_events: list[str] = []
|
||||
|
||||
class EB:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class TestPathUtilities:
|
|||
result = safe_relative_path(file, base, fallback)
|
||||
assert "video.mp4" == result, "Should use fallback path"
|
||||
|
||||
def test_safe_relative_path_no_fallback_returns_absolute(self) -> None:
|
||||
def test_safe_rel_no_fallback(self) -> None:
|
||||
base = Path("/wrong/path")
|
||||
file = Path("/downloads/video.mp4")
|
||||
result = safe_relative_path(file, base)
|
||||
|
|
@ -73,13 +73,13 @@ class TestPathUtilities:
|
|||
|
||||
|
||||
class TestProcessUtilities:
|
||||
def test_wait_for_process_with_timeout_completes_immediately(self) -> None:
|
||||
def test_wait_timeout_done(self) -> None:
|
||||
proc = Mock()
|
||||
proc.is_alive = Mock(return_value=False)
|
||||
result = wait_for_process_with_timeout(proc, timeout=1.0)
|
||||
assert result is True, "Should return True when process terminates immediately"
|
||||
|
||||
def test_wait_for_process_with_timeout_completes_after_delay(self) -> None:
|
||||
def test_wait_timeout_delay(self) -> None:
|
||||
proc = Mock()
|
||||
call_count = [0]
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ class TestConfigUtilities:
|
|||
result = parse_extractor_limit("youtube", default_limit=5, max_workers=10)
|
||||
assert 10 == result, "Should cap at max_workers"
|
||||
|
||||
def test_parse_extractor_limit_invalid_env_not_digit(self) -> None:
|
||||
def test_parse_limit_nondigit(self) -> None:
|
||||
logger = logging.getLogger("test")
|
||||
with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "abc"}):
|
||||
result = parse_extractor_limit("youtube", default_limit=5, max_workers=10, logger=logger)
|
||||
|
|
@ -138,11 +138,11 @@ class TestConfigUtilities:
|
|||
result = parse_extractor_limit("youtube", default_limit=5, max_workers=10)
|
||||
assert 5 == result, "Should use default when no env var set"
|
||||
|
||||
def test_parse_extractor_limit_respects_max_on_default(self) -> None:
|
||||
def test_parse_limit_default_max(self) -> None:
|
||||
result = parse_extractor_limit("youtube", default_limit=15, max_workers=10)
|
||||
assert 10 == result, "Should cap default at max_workers"
|
||||
|
||||
def test_parse_extractor_limit_logs_warning_on_invalid(self) -> None:
|
||||
def test_parse_limit_warns(self) -> None:
|
||||
logger = Mock()
|
||||
with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "invalid"}):
|
||||
parse_extractor_limit("youtube", default_limit=5, max_workers=10, logger=logger)
|
||||
|
|
@ -195,7 +195,7 @@ class TestDataUtilities:
|
|||
assert "custom_field" not in result["info_dict"], "Should exclude custom field"
|
||||
assert "123" == result["info_dict"]["id"], "Should include id"
|
||||
|
||||
def test_create_debug_safe_dict_filters_none_and_functions(self) -> None:
|
||||
def test_debug_safe_filters_none(self) -> None:
|
||||
data = {
|
||||
"status": "downloading",
|
||||
"info_dict": {"id": "123", "none_value": None, "lambda_value": lambda: None, "title": "Video"},
|
||||
|
|
@ -212,7 +212,7 @@ class TestDataUtilities:
|
|||
|
||||
|
||||
class TestStateUtilities:
|
||||
def test_is_download_stale_terminal_status_finished(self) -> None:
|
||||
def test_stale_finished(self) -> None:
|
||||
result = is_download_stale(
|
||||
started_time=int(time.time()) - 500, current_status="finished", is_running=False, auto_start=True
|
||||
)
|
||||
|
|
@ -224,19 +224,19 @@ class TestStateUtilities:
|
|||
)
|
||||
assert result is False, "Error downloads are never stale"
|
||||
|
||||
def test_is_download_stale_terminal_status_cancelled(self) -> None:
|
||||
def test_stale_cancelled(self) -> None:
|
||||
result = is_download_stale(
|
||||
started_time=int(time.time()) - 500, current_status="cancelled", is_running=False, auto_start=True
|
||||
)
|
||||
assert result is False, "Cancelled downloads are never stale"
|
||||
|
||||
def test_is_download_stale_terminal_status_downloading(self) -> None:
|
||||
def test_stale_downloading(self) -> None:
|
||||
result = is_download_stale(
|
||||
started_time=int(time.time()) - 500, current_status="downloading", is_running=False, auto_start=True
|
||||
)
|
||||
assert result is False, "Downloading status is never stale"
|
||||
|
||||
def test_is_download_stale_terminal_status_postprocessing(self) -> None:
|
||||
def test_stale_postprocessing(self) -> None:
|
||||
result = is_download_stale(
|
||||
started_time=int(time.time()) - 500, current_status="postprocessing", is_running=False, auto_start=True
|
||||
)
|
||||
|
|
@ -303,7 +303,7 @@ class TestTaskExceptionHandling:
|
|||
logger.error.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_task_exception_ignores_successful(self) -> None:
|
||||
async def test_task_exception_success(self) -> None:
|
||||
logger = Mock()
|
||||
|
||||
async def successful_task():
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class TestEncoder:
|
|||
result = self.encoder.default(obj)
|
||||
assert result == {"name": "test", "value": 42}
|
||||
|
||||
def test_object_without_dict_fallback_to_default(self):
|
||||
def test_object_default(self):
|
||||
"""Test that objects without __dict__ fall back to default JSONEncoder."""
|
||||
# This should raise TypeError since complex is not JSON serializable
|
||||
with pytest.raises(TypeError):
|
||||
|
|
|
|||
|
|
@ -460,7 +460,7 @@ class TestEvent:
|
|||
assert f"handler3_{i}" in results
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_sync_async_handlers_execution_order(self):
|
||||
async def test_mixed_handlers_order(self):
|
||||
"""Test that mixed sync/async handlers execute properly without blocking."""
|
||||
bus = EventBus()
|
||||
execution_times = []
|
||||
|
|
@ -511,7 +511,7 @@ class TestEvent:
|
|||
class TestEventListener:
|
||||
"""Test the EventListener class."""
|
||||
|
||||
def test_event_listener_creation_with_sync_callback(self):
|
||||
def test_listener_sync_init(self):
|
||||
"""Test creating EventListener with synchronous callback."""
|
||||
|
||||
def sync_callback(event, name, **kwargs): # noqa: ARG001
|
||||
|
|
@ -523,7 +523,7 @@ class TestEventListener:
|
|||
assert listener.call_back == sync_callback
|
||||
assert listener.is_coroutine is False
|
||||
|
||||
def test_event_listener_creation_with_async_callback(self):
|
||||
def test_listener_async_init(self):
|
||||
"""Test creating EventListener with asynchronous callback."""
|
||||
|
||||
async def async_callback(event, name, **kwargs): # noqa: ARG001
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ def _make_download(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_items_delete_uses_bulk_history_status_clear() -> None:
|
||||
async def test_items_delete_status() -> None:
|
||||
request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "status": "finished,skip", "remove_file": False})
|
||||
queue = Mock()
|
||||
queue.clear_by_status = AsyncMock(return_value={"deleted": 12})
|
||||
|
|
@ -67,7 +67,7 @@ async def test_items_delete_uses_bulk_history_status_clear() -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_items_delete_uses_bulk_history_id_clear() -> None:
|
||||
async def test_items_delete_ids() -> None:
|
||||
request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "ids": ["a", "b"], "remove_file": False})
|
||||
queue = Mock()
|
||||
queue.clear_bulk = AsyncMock(return_value={"deleted": 2})
|
||||
|
|
@ -82,7 +82,7 @@ async def test_items_delete_uses_bulk_history_id_clear() -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_item_rename_requires_new_name() -> None:
|
||||
async def test_item_rename_needs_name() -> None:
|
||||
request = _FakeRequest(payload={})
|
||||
request.match_info["id"] = "item-1"
|
||||
queue = SimpleNamespace(
|
||||
|
|
@ -100,7 +100,7 @@ async def test_item_rename_requires_new_name() -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_item_rename_returns_not_found_when_item_missing() -> None:
|
||||
async def test_item_rename_missing() -> None:
|
||||
request = _FakeRequest(payload={"new_name": "renamed.mp4"})
|
||||
request.match_info["id"] = "missing"
|
||||
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=None)))
|
||||
|
|
@ -116,7 +116,7 @@ async def test_item_rename_returns_not_found_when_item_missing() -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_item_rename_requires_existing_downloaded_file() -> None:
|
||||
async def test_item_rename_needs_file() -> None:
|
||||
request = _FakeRequest(payload={"new_name": "renamed.mp4"})
|
||||
request.match_info["id"] = "item-1"
|
||||
item = _make_download(filename="video.mp4")
|
||||
|
|
@ -135,7 +135,7 @@ async def test_item_rename_requires_existing_downloaded_file() -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_item_rename_renames_file_and_sidecars() -> None:
|
||||
async def test_item_rename_sidecars() -> None:
|
||||
with temporary_test_dir("history-rename") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
subtitle = temp_dir / "video.en.srt"
|
||||
|
|
@ -166,7 +166,7 @@ async def test_item_rename_renames_file_and_sidecars() -> None:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_item_rename_returns_conflict_on_collision() -> None:
|
||||
async def test_item_rename_conflict() -> None:
|
||||
with temporary_test_dir("history-rename-conflict") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
conflict = temp_dir / "renamed.mp4"
|
||||
|
|
|
|||
|
|
@ -356,7 +356,7 @@ class TestAsyncClient:
|
|||
assert isinstance(client._transport, CFAsyncTransport)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_client_cf_disabled(self):
|
||||
async def test_async_client_custom_cf_off(self):
|
||||
"""Test creating async client with CF disabled."""
|
||||
async with async_client(enable_cf=False) as client:
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
|
|
@ -381,7 +381,7 @@ class TestAsyncClient:
|
|||
assert client._transport.base is custom
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_client_custom_transport_cf_disabled(self):
|
||||
async def test_async_client_cf_disabled(self):
|
||||
"""Test creating async client with custom transport and CF disabled."""
|
||||
custom = httpx.AsyncHTTPTransport()
|
||||
async with async_client(enable_cf=False, transport=custom) as client:
|
||||
|
|
@ -400,7 +400,7 @@ class TestSyncClient:
|
|||
assert isinstance(client._transport, CFTransport)
|
||||
client.close()
|
||||
|
||||
def test_sync_client_cf_disabled(self):
|
||||
def test_sync_client_custom_cf_off(self):
|
||||
"""Test creating sync client with CF disabled."""
|
||||
client = sync_client(enable_cf=False)
|
||||
assert isinstance(client, httpx.Client)
|
||||
|
|
@ -425,7 +425,7 @@ class TestSyncClient:
|
|||
assert client._transport.base is custom
|
||||
client.close()
|
||||
|
||||
def test_sync_client_custom_transport_cf_disabled(self):
|
||||
def test_sync_client_cf_disabled(self):
|
||||
"""Test creating sync client with custom transport and CF disabled."""
|
||||
custom = httpx.HTTPTransport()
|
||||
client = sync_client(enable_cf=False, transport=custom)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class TestItemFormatAndBasics:
|
|||
assert item.cli == "--embed-metadata"
|
||||
|
||||
@patch("app.features.presets.service.Presets.get_instance")
|
||||
def test_format_raises_for_missing_url_and_invalid_preset(self, mock_presets_get):
|
||||
def test_format_bad_input(self, mock_presets_get):
|
||||
# Missing url
|
||||
with pytest.raises(ValueError, match="url param is required"):
|
||||
Item.format({})
|
||||
|
|
@ -113,7 +113,7 @@ class TestItemDTO:
|
|||
@patch("app.library.ItemDTO.get_archive_id")
|
||||
@patch("app.library.ItemDTO.YTDLPOpts")
|
||||
@patch("app.library.ItemDTO.archive_read")
|
||||
def test_post_init_does_not_infer_download_skipped_flag(self, mock_read, mock_opts, mock_get_id):
|
||||
def test_post_init_keeps_skipped(self, mock_read, mock_opts, mock_get_id):
|
||||
mock_get_id.return_value = {"archive_id": "arch", "id": "arch", "ie_key": "YT"}
|
||||
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
|
||||
|
|
@ -128,7 +128,7 @@ class TestItemDTO:
|
|||
assert dto.download_skipped is False
|
||||
|
||||
@patch("app.library.ItemDTO.archive_read")
|
||||
def test_serialize_triggers_archive_status_when_finished(self, mock_read):
|
||||
def test_serialize_archives_finished(self, mock_read):
|
||||
# Given a finished item with archive info
|
||||
dto = ItemDTO(id="vid", title="t", url="u", folder="f")
|
||||
dto.archive_id = "arch"
|
||||
|
|
@ -143,7 +143,7 @@ class TestItemDTO:
|
|||
assert key not in data
|
||||
|
||||
@patch("app.library.ItemDTO.YTDLPOpts")
|
||||
def test_serialize_does_not_recompute_download_skipped(self, mock_opts):
|
||||
def test_serialize_keeps_skipped(self, mock_opts):
|
||||
mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value
|
||||
mock_opts.get_instance.return_value.get_all.return_value = {}
|
||||
|
|
@ -299,7 +299,7 @@ class TestItemDTO:
|
|||
assert result is expected_sidecar
|
||||
assert dto.sidecar is expected_sidecar
|
||||
|
||||
def test_get_file_sidecar_returns_existing_when_no_file(self):
|
||||
def test_sidecar_no_file(self):
|
||||
with patch.object(ItemDTO, "__post_init__", lambda _: None):
|
||||
dto = ItemDTO(id="sidecar-none", title="Title", url="u", folder="f")
|
||||
|
||||
|
|
@ -317,7 +317,7 @@ class TestItemDTO:
|
|||
assert result is existing
|
||||
assert dto.sidecar is existing
|
||||
|
||||
def test_get_preset_returns_preset_instance(self):
|
||||
def test_get_preset_hit(self):
|
||||
"""Test ItemDTO.get_preset returns the Preset instance."""
|
||||
from app.features.presets.schemas import Preset
|
||||
|
||||
|
|
@ -335,7 +335,7 @@ class TestItemDTO:
|
|||
assert result is mock_preset
|
||||
assert result.name == "test-preset"
|
||||
|
||||
def test_get_preset_uses_default_when_no_preset_set(self):
|
||||
def test_get_preset_default(self):
|
||||
"""Test ItemDTO.get_preset uses 'default' when preset is empty."""
|
||||
from app.features.presets.schemas import Preset
|
||||
|
||||
|
|
@ -352,7 +352,7 @@ class TestItemDTO:
|
|||
mock_presets.return_value.get.assert_called_once_with("default")
|
||||
assert result is mock_preset
|
||||
|
||||
def test_get_preset_returns_none_when_not_found(self):
|
||||
def test_get_preset_miss(self):
|
||||
"""Test ItemDTO.get_preset returns None when preset not found."""
|
||||
with patch.object(ItemDTO, "__post_init__", lambda _: None):
|
||||
dto = ItemDTO(id="vid", title="t", url="u", folder="f", preset="nonexistent")
|
||||
|
|
@ -376,7 +376,7 @@ class TestItemAddExtras:
|
|||
|
||||
assert item.extras["key1"] == "value1"
|
||||
|
||||
def test_add_extras_when_extras_is_none(self):
|
||||
def test_add_extras_none(self):
|
||||
"""Test adding extras when extras is None."""
|
||||
item = Item(url="https://example.com")
|
||||
item.extras = None
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ class TestPackages:
|
|||
|
||||
|
||||
class TestPackageInstallerInit:
|
||||
def test_init_with_explicit_path_adds_to_sys_path(self, tmp_path: Path) -> None:
|
||||
def test_init_adds_sys_path(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "site"
|
||||
p.mkdir()
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ class TestPackageInstallerInit:
|
|||
assert installer.user_site.exists() is True
|
||||
assert str(installer.user_site) in sys.path
|
||||
|
||||
def test_init_without_path_or_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_init_no_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("YTP_CONFIG_PATH", raising=False)
|
||||
installer = PackageInstaller(pkg_path=None)
|
||||
# No user_site is set when no path or env provided
|
||||
|
|
@ -206,7 +206,7 @@ class TestInstallCmd:
|
|||
class TestActionAndCheck:
|
||||
@patch.object(PackageInstaller, "_install_pkg")
|
||||
@patch.object(PackageInstaller, "_get_installed_version")
|
||||
def test_action_skips_when_same_pinned(self, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
||||
def test_action_skip_pinned(self, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_get_installed.return_value = "1.2.3"
|
||||
# compare_versions normal equality should hold
|
||||
|
|
@ -216,9 +216,7 @@ class TestActionAndCheck:
|
|||
@patch.object(PackageInstaller, "_install_pkg")
|
||||
@patch.object(PackageInstaller, "_get_installed_version")
|
||||
@patch.object(PackageInstaller, "_get_latest_version")
|
||||
def test_action_upgrade_skip_when_latest(
|
||||
self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path
|
||||
) -> None:
|
||||
def test_action_skip_latest(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_get_installed.return_value = "2.0.0"
|
||||
mock_get_latest.return_value = "2.0.0"
|
||||
|
|
@ -228,9 +226,7 @@ class TestActionAndCheck:
|
|||
@patch.object(PackageInstaller, "_install_pkg")
|
||||
@patch.object(PackageInstaller, "_get_installed_version")
|
||||
@patch.object(PackageInstaller, "_get_latest_version")
|
||||
def test_action_upgrade_runs_when_newer_available(
|
||||
self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path
|
||||
) -> None:
|
||||
def test_action_upgrade_newer(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_get_installed.return_value = "1.0.0"
|
||||
mock_get_latest.return_value = "1.1.0"
|
||||
|
|
@ -239,13 +235,13 @@ class TestActionAndCheck:
|
|||
|
||||
@patch.object(PackageInstaller, "_install_pkg")
|
||||
@patch.object(PackageInstaller, "_get_installed_version")
|
||||
def test_action_install_when_not_installed(self, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
||||
def test_action_install_missing(self, mock_get_installed, mock_install, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
mock_get_installed.return_value = None
|
||||
inst.action("pkg")
|
||||
mock_install.assert_called_once_with("pkg", version=None)
|
||||
|
||||
def test_check_with_no_packages_or_no_user_site(self, tmp_path: Path) -> None:
|
||||
def test_check_no_packages_or_path(self, tmp_path: Path) -> None:
|
||||
# No packages
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
pkgs = Packages(env=None, file=None, upgrade=False)
|
||||
|
|
@ -257,7 +253,7 @@ class TestActionAndCheck:
|
|||
inst2.check(pkgs2)
|
||||
|
||||
@patch.object(PackageInstaller, "action")
|
||||
def test_check_calls_action_and_handles_errors(self, mock_action, tmp_path: Path) -> None:
|
||||
def test_check_runs_all(self, mock_action, tmp_path: Path) -> None:
|
||||
inst = PackageInstaller(pkg_path=tmp_path)
|
||||
pkgs = Packages(env="foo bar", file=None, upgrade=True)
|
||||
|
||||
|
|
@ -272,3 +268,5 @@ class TestActionAndCheck:
|
|||
# Should not raise
|
||||
inst.check(pkgs)
|
||||
assert mock_action.call_count == 2
|
||||
mock_action.assert_any_call("foo", upgrade=True)
|
||||
mock_action.assert_any_call("bar", upgrade=True)
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ class TestAddRoute:
|
|||
assert r.method == "POST"
|
||||
assert r.path == "/api/create/"
|
||||
|
||||
def test_add_route_socket_without_alias(self) -> None:
|
||||
def test_add_route_socket_no_alias(self) -> None:
|
||||
async def s():
|
||||
return "s"
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ class TestAddRoute:
|
|||
|
||||
|
||||
class TestGetters:
|
||||
def test_get_routes_returns_copy_like_mapping(self) -> None:
|
||||
def test_get_routes_copy(self) -> None:
|
||||
async def h():
|
||||
return "x"
|
||||
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ class TestScheduler:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.Scheduler.Cron", new=DummyCron)
|
||||
async def test_attach_registers_shutdown_and_handles_schedule_add_event(self) -> None:
|
||||
async def test_attach_registers_events(self) -> None:
|
||||
from aiohttp import web
|
||||
|
||||
app = web.Application()
|
||||
|
|
@ -224,7 +224,7 @@ class TestScheduler:
|
|||
assert kwargs["id"] == "evt-job"
|
||||
|
||||
@patch("app.library.Scheduler.Cron")
|
||||
def test_add_executes_function_when_cron_runs(self, cron_patch) -> None:
|
||||
def test_add_executes_on_start(self, cron_patch) -> None:
|
||||
# Cron stub that auto-runs the function on creation when start=True
|
||||
class AutoRunCron(DummyCron):
|
||||
def __init__(
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ class TestServices:
|
|||
assert services.get("service3") == "value3"
|
||||
assert len(services.get_all()) == 3
|
||||
|
||||
def test_get_all_returns_copy(self):
|
||||
def test_get_all_copy(self):
|
||||
"""Test that get_all returns a copy, not the original dict."""
|
||||
services = Services()
|
||||
services.add("test", "value")
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ def make_item(idx: int, *, status: str = "finished", cli: str = "", download_ski
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessionmaker_returns_valid_sessionmaker() -> None:
|
||||
async def test_sessionmaker_ready() -> None:
|
||||
"""Test that sessionmaker() returns a working async_sessionmaker."""
|
||||
SqliteStore._reset_singleton()
|
||||
store = SqliteStore.get_instance(db_path=make_in_memory_db_path("sessionmaker"))
|
||||
|
|
@ -120,7 +120,7 @@ async def test_enqueue_upsert_and_fetch_saved():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_upsert_persists_download_skipped_flag():
|
||||
async def test_enqueue_upsert_skipped():
|
||||
store = await make_store()
|
||||
item = make_item(2, download_skipped=True)
|
||||
|
||||
|
|
@ -237,7 +237,7 @@ async def test_enqueue_delete_removes_row():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_bulk_delete_returns_count_and_bulk_path():
|
||||
async def test_enqueue_bulk_delete():
|
||||
store = await make_store()
|
||||
items = [make_item(i) for i in range(3)]
|
||||
for itm in items:
|
||||
|
|
@ -258,7 +258,7 @@ async def test_enqueue_bulk_delete_returns_count_and_bulk_path():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_many_by_ids_and_status_and_bulk_delete_by_status():
|
||||
async def test_ids_status_bulk_delete():
|
||||
store = await make_store()
|
||||
finished = [make_item(i, status="finished") for i in range(2)]
|
||||
pending = [make_item(i + 10, status="pending") for i in range(2)]
|
||||
|
|
@ -296,7 +296,7 @@ async def test_get_many_by_ids_and_status_and_bulk_delete_by_status():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paginate_out_of_range_returns_last_page():
|
||||
async def test_paginate_last_page():
|
||||
store = await make_store()
|
||||
for i in range(7):
|
||||
await store.enqueue_upsert("history", make_item(i))
|
||||
|
|
@ -311,7 +311,7 @@ async def test_paginate_out_of_range_returns_last_page():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_item_returns_none_without_kwargs():
|
||||
async def test_get_item_no_filters():
|
||||
store = await make_store()
|
||||
await store.enqueue_upsert("queue", make_item(1))
|
||||
await store.flush()
|
||||
|
|
@ -387,7 +387,7 @@ async def test_exists_and_get_by_key_and_url():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exists_and_get_raise_without_key_or_url():
|
||||
async def test_exists_and_get_require_lookup():
|
||||
store = await make_store()
|
||||
with pytest.raises(KeyError):
|
||||
await store.exists("queue")
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ def _configure_static_root(static_root: Path) -> None:
|
|||
|
||||
class TestServeStaticFile:
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_document_route_falls_back_to_spa_shell(self, tmp_path: Path) -> None:
|
||||
async def test_nested_doc_falls_back(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
index_file = tmp_path / "index.html"
|
||||
index_file.write_text("<html>root shell</html>", encoding="utf-8")
|
||||
|
|
@ -46,7 +46,7 @@ class TestServeStaticFile:
|
|||
assert response._path == index_file
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_nested_index_is_not_preferred_over_root_shell(self, tmp_path: Path) -> None:
|
||||
async def test_nested_index_uses_root(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
root_index = tmp_path / "index.html"
|
||||
nested_index = tmp_path / "docs" / "readme" / "index.html"
|
||||
|
|
@ -82,7 +82,7 @@ class TestServeStaticFile:
|
|||
assert b"missing.js" in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_asset_with_unknown_suffix_does_not_fall_back(self, tmp_path: Path) -> None:
|
||||
async def test_missing_unknown_no_fallback(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
|
||||
assets_dir = tmp_path / "assets"
|
||||
|
|
@ -103,7 +103,7 @@ class TestServeStaticFile:
|
|||
assert b"missing.abcd123" in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_symlink_outside_static_root_does_not_resolve(self, tmp_path: Path) -> None:
|
||||
async def test_symlink_outside_rejected(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
|
||||
outside_dir = tmp_path.parent / "outside-static-root"
|
||||
|
|
@ -144,7 +144,7 @@ class TestServeStaticFile:
|
|||
assert b"/api/missing" in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dotted_browser_path_returns_not_found(self, tmp_path: Path) -> None:
|
||||
async def test_dotted_browser_path_404(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
(tmp_path / "index.html").write_text("<html>root shell</html>", encoding="utf-8")
|
||||
_configure_static_root(tmp_path)
|
||||
|
|
@ -157,7 +157,7 @@ class TestServeStaticFile:
|
|||
assert response.status == web.HTTPNotFound.status_code
|
||||
assert b"/browser/foo/bar.txt" in body
|
||||
|
||||
def test_registers_only_root_and_catch_all_routes(self, tmp_path: Path) -> None:
|
||||
def test_registers_root_routes(self, tmp_path: Path) -> None:
|
||||
config = Config.get_instance()
|
||||
static_root = tmp_path / "ui-exported"
|
||||
static_root.mkdir()
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ class TestSystemLimitsEndpoint:
|
|||
Config._reset_singleton()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_limits_returns_user_facing_limits(self):
|
||||
async def test_system_limits_public(self):
|
||||
config = Config.get_instance()
|
||||
config.max_workers = 10
|
||||
config.max_workers_per_extractor = 3
|
||||
|
|
@ -188,7 +188,7 @@ class TestSystemLimitsEndpoint:
|
|||
"available": 3,
|
||||
}
|
||||
|
||||
def test_config_reads_prevent_live_premiere_boolean_env(self):
|
||||
def test_config_reads_live_premiere(self):
|
||||
with patch.dict("os.environ", {"YTP_PREVENT_LIVE_PREMIERE": "false"}, clear=False):
|
||||
Config._reset_singleton()
|
||||
config = Config.get_instance()
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ class TestTerminalSessionRoutes:
|
|||
assert manager._cleanup_job_id == f"{TerminalSessionManager.__name__}.{TerminalSessionManager.cleanup.__name__}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_returns_session_metadata_and_active_conflict(
|
||||
async def test_start_conflict_meta(
|
||||
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -206,7 +206,7 @@ class TestTerminalSessionRoutes:
|
|||
await task
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_endpoint_replays_persisted_events_and_resume_cursor(
|
||||
async def test_stream_replays_resume(
|
||||
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -256,7 +256,7 @@ class TestTerminalSessionRoutes:
|
|||
assert "id: 3" in resumed_payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_session_expires_after_drain_window(
|
||||
async def test_completed_expires(
|
||||
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -293,7 +293,7 @@ class TestTerminalSessionRoutes:
|
|||
assert not (manager.root_path / session_id).exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_returns_recent_completed_session_until_retention_expires(
|
||||
async def test_list_keeps_recent_done(
|
||||
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -341,9 +341,7 @@ class TestTerminalSessionRoutes:
|
|||
assert not (manager.root_path / session_id).exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_orders_newest_first_and_skips_expired_items(
|
||||
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder]
|
||||
) -> None:
|
||||
async def test_list_orders_newest(self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder]) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
await manager.initialize()
|
||||
|
||||
|
|
@ -411,7 +409,7 @@ class TestTerminalSessionRoutes:
|
|||
assert not (manager.root_path / expired_id).exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_interrupts_active_session_and_clears_active_marker(
|
||||
async def test_shutdown_clears_active(
|
||||
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -451,7 +449,7 @@ class TestTerminalSessionRoutes:
|
|||
assert -15 == transcript[-1]["data"]["exitcode"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_endpoint_emits_keepalive_for_silent_active_session(
|
||||
async def test_stream_keepalive(
|
||||
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -489,7 +487,7 @@ class TestTerminalSessionRoutes:
|
|||
assert 'data: {"exitcode": 0}' in stream_payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_endpoint_interrupts_active_session(
|
||||
async def test_cancel_active(
|
||||
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -535,7 +533,7 @@ class TestTerminalSessionRoutes:
|
|||
assert -15 == transcript[-1]["data"]["exitcode"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_endpoint_returns_conflict_for_inactive_session(
|
||||
async def test_cancel_inactive_conflict(
|
||||
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
|
|
@ -564,9 +562,7 @@ class TestTerminalSessionRoutes:
|
|||
assert b"not active" in cancel_response.body.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_endpoint_returns_not_found_for_unknown_session(
|
||||
self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder]
|
||||
) -> None:
|
||||
async def test_cancel_unknown(self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder]) -> None:
|
||||
config, manager, encoder = terminal_setup
|
||||
await manager.initialize()
|
||||
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ from app.tests.helpers import (
|
|||
)
|
||||
|
||||
|
||||
def test_make_test_disk_path_uses_test_run_root() -> None:
|
||||
def test_disk_path_root() -> None:
|
||||
path = make_test_disk_path("artifacts", "example.txt")
|
||||
|
||||
assert path.parent.exists()
|
||||
assert path.is_relative_to(get_test_run_root())
|
||||
|
||||
|
||||
def test_make_test_temp_dir_creates_directory() -> None:
|
||||
def test_temp_dir_created() -> None:
|
||||
path = make_test_temp_dir("helpers")
|
||||
|
||||
assert path.exists()
|
||||
|
|
@ -25,7 +25,7 @@ def test_make_test_temp_dir_creates_directory() -> None:
|
|||
assert path.is_relative_to(get_test_run_root())
|
||||
|
||||
|
||||
def test_tmp_path_runs_under_custom_temp_root(tmp_path: Path) -> None:
|
||||
def test_tmp_path_root(tmp_path: Path) -> None:
|
||||
expected_root = get_test_run_root() / "pytest"
|
||||
|
||||
assert tmp_path.is_relative_to(expected_root)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class TestUpdateChecker:
|
|||
EventBus._reset_singleton()
|
||||
Cache._reset_singleton()
|
||||
|
||||
def test_attach_schedules_check_when_enabled(self):
|
||||
def test_attach_enabled(self):
|
||||
"""Test that attach schedules update check when config.check_for_updates is True."""
|
||||
import asyncio
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ class TestUpdateChecker:
|
|||
scheduler.remove(checker._job_id)
|
||||
loop.close()
|
||||
|
||||
def test_attach_skips_scheduling_when_disabled(self):
|
||||
def test_attach_disabled(self):
|
||||
"""Test that attach skips scheduling when config.check_for_updates is False."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
|
@ -111,7 +111,7 @@ class TestUpdateChecker:
|
|||
loop.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_for_updates_skips_when_disabled(self):
|
||||
async def test_check_for_updates_disabled(self):
|
||||
"""Test that check_for_updates skips when config.check_for_updates is False."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
|
@ -255,7 +255,7 @@ class TestUpdateChecker:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.UpdateChecker.get_async_client")
|
||||
async def test_check_ytdlp_version_handles_http_error(self, mock_client):
|
||||
async def test_check_ytdlp_version_http_error(self, mock_client):
|
||||
"""Test that yt-dlp check handles HTTP errors gracefully."""
|
||||
from app.library.config import Config
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import asyncio
|
||||
import copy
|
||||
import importlib
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
|
|
@ -30,7 +31,6 @@ from app.library.Utils import (
|
|||
is_private_address,
|
||||
list_folders,
|
||||
load_cookies,
|
||||
load_modules,
|
||||
merge_dict,
|
||||
move_file,
|
||||
parse_tags,
|
||||
|
|
@ -48,7 +48,7 @@ from app.tests.helpers import make_test_temp_dir, temporary_test_dir
|
|||
class TestTimedLruCache:
|
||||
"""Test the timed_lru_cache decorator."""
|
||||
|
||||
def test_timed_lru_cache_basic_functionality(self):
|
||||
def test_basic(self):
|
||||
"""Test that timed_lru_cache caches function results."""
|
||||
call_count = 0
|
||||
|
||||
|
|
@ -332,39 +332,39 @@ class TestCalcDownloadPath:
|
|||
result = calc_download_path(str(self.base_path), None, create_path=False)
|
||||
assert result == str(self.base_path), "Should return base path when folder is None"
|
||||
|
||||
def test_calc_download_path_removes_leading_slash(self):
|
||||
def test_calc_path_strips_leading_slash(self):
|
||||
"""Test that leading slash is removed from folder."""
|
||||
folder = "/test_folder"
|
||||
result = calc_download_path(str(self.base_path), folder, create_path=False)
|
||||
expected = str(self.base_path / "test_folder")
|
||||
assert result == expected, "Should remove leading slash from folder"
|
||||
|
||||
def test_calc_download_path_path_traversal_dotdot(self):
|
||||
def test_calc_path_dotdot(self):
|
||||
"""Test path traversal prevention with .. sequences."""
|
||||
folder = "../outside"
|
||||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
calc_download_path(str(self.base_path), folder, create_path=False)
|
||||
|
||||
def test_calc_download_path_path_traversal_nested_dotdot(self):
|
||||
def test_calc_path_nested_dotdot(self):
|
||||
"""Test path traversal prevention with nested .. sequences."""
|
||||
folder = "safe/../../outside"
|
||||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
calc_download_path(str(self.base_path), folder, create_path=False)
|
||||
|
||||
def test_calc_download_path_path_traversal_multiple_dotdot(self):
|
||||
def test_calc_path_multi_dotdot(self):
|
||||
"""Test path traversal prevention with multiple .. sequences."""
|
||||
folder = "../../../etc/passwd"
|
||||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
calc_download_path(str(self.base_path), folder, create_path=False)
|
||||
|
||||
def test_calc_download_path_path_traversal_absolute_path(self):
|
||||
def test_calc_path_absolute(self):
|
||||
"""Test that absolute paths are made safe by removing leading slash."""
|
||||
folder = "/etc/passwd"
|
||||
result = calc_download_path(str(self.base_path), folder, create_path=False)
|
||||
expected = str(self.base_path / "etc/passwd")
|
||||
assert result == expected, "Should remove leading slash and treat as relative path"
|
||||
|
||||
def test_calc_download_path_path_traversal_absolute_with_dotdot(self):
|
||||
def test_calc_path_absolute_dotdot(self):
|
||||
"""Test path traversal with absolute path containing .. sequences."""
|
||||
folder = "/../../../etc/passwd"
|
||||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
|
|
@ -376,7 +376,7 @@ class TestCalcDownloadPath:
|
|||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
calc_download_path(str(self.base_path), folder, create_path=False)
|
||||
|
||||
def test_calc_download_path_path_traversal_url_encoded(self):
|
||||
def test_calc_path_url_encoded(self):
|
||||
"""Test path traversal prevention with URL encoded sequences."""
|
||||
folder = "safe%2F..%2F..%2Funsafe" # safe/../unsafe encoded
|
||||
# This should be handled at a higher level, but let's test it anyway
|
||||
|
|
@ -449,7 +449,7 @@ class TestCalcDownloadPath:
|
|||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
calc_download_path(str(self.base_path), folder, create_path=False)
|
||||
|
||||
def test_calc_download_path_carriage_return_attack(self):
|
||||
def test_calc_path_carriage_return(self):
|
||||
"""Test that carriage returns in path traversal are prevented."""
|
||||
folder = "folder\r../../../etc/passwd"
|
||||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
|
|
@ -479,7 +479,7 @@ class TestCalcDownloadPath:
|
|||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
calc_download_path(str(self.base_path), folder, create_path=False)
|
||||
|
||||
def test_calc_download_path_url_encoded_traversal_safe(self):
|
||||
def test_calc_path_url_encoded_safe(self):
|
||||
"""Test that URL encoded path traversal is treated as literal (safe)."""
|
||||
folder = "folder..%2F..%2F..%2Fetc%2Fpasswd" # ..%2F = ../ encoded
|
||||
result = calc_download_path(str(self.base_path), folder, create_path=False)
|
||||
|
|
@ -494,7 +494,7 @@ class TestCalcDownloadPath:
|
|||
expected = str(self.base_path / folder)
|
||||
assert result == expected, "Backslashes should be treated as literal characters on Unix"
|
||||
|
||||
def test_calc_download_path_mixed_separators_attack(self):
|
||||
def test_calc_path_mixed_separators(self):
|
||||
"""Test path traversal with mixed separators."""
|
||||
folder = "folder/../../../etc/passwd"
|
||||
with pytest.raises(Exception, match="must resolve inside the base download folder"):
|
||||
|
|
@ -520,7 +520,7 @@ class TestCalcDownloadPath:
|
|||
# Clean up
|
||||
shutil.rmtree(sibling_dir, ignore_errors=True)
|
||||
|
||||
def test_calc_download_path_symlink_attack_outside(self):
|
||||
def test_calc_path_symlink_outside(self):
|
||||
"""Test that symlinks pointing outside base directory are blocked."""
|
||||
# Create a symlink pointing outside the base directory
|
||||
outside_dir = Path(self.temp_dir).parent / "outside_target"
|
||||
|
|
@ -539,7 +539,7 @@ class TestCalcDownloadPath:
|
|||
symlink_path.unlink()
|
||||
shutil.rmtree(outside_dir, ignore_errors=True)
|
||||
|
||||
def test_calc_download_path_symlink_attack_with_traversal(self):
|
||||
def test_calc_path_symlink_traversal(self):
|
||||
"""Test symlink combined with path traversal."""
|
||||
# Create a directory outside base
|
||||
outside_dir = Path(self.temp_dir).parent / "target_dir"
|
||||
|
|
@ -563,7 +563,7 @@ class TestCalcDownloadPath:
|
|||
shutil.rmtree(safe_dir, ignore_errors=True)
|
||||
shutil.rmtree(outside_dir, ignore_errors=True)
|
||||
|
||||
def test_calc_download_path_symlink_safe_internal(self):
|
||||
def test_calc_path_symlink_internal(self):
|
||||
"""Test that symlinks pointing inside base directory are allowed."""
|
||||
# Create target directory inside base
|
||||
target_dir = self.base_path / "target"
|
||||
|
|
@ -602,7 +602,7 @@ class TestCalcDownloadPath:
|
|||
expected = str(self.base_path / deep_path)
|
||||
assert result == expected, "Should handle deeply nested paths"
|
||||
|
||||
def test_calc_download_path_directory_with_spaces(self):
|
||||
def test_calc_path_spaces(self):
|
||||
"""Test paths with multiple spaces and special spacing."""
|
||||
folder = "folder with multiple spaces"
|
||||
result = calc_download_path(str(self.base_path), folder, create_path=True)
|
||||
|
|
@ -697,7 +697,7 @@ class TestMergeDict:
|
|||
assert "__builtins__" not in result, "__builtins__ should be filtered out"
|
||||
assert result["normal"] == "value", "Normal values should be preserved"
|
||||
|
||||
def test_merge_dict_blocks_multiple_dunder_pollution(self):
|
||||
def test_merge_dict_blocks_dunders(self):
|
||||
"""Test that multiple dangerous dunder attributes are blocked."""
|
||||
source = {
|
||||
"__class__": "malicious",
|
||||
|
|
@ -863,7 +863,7 @@ class TestMergeDict:
|
|||
assert len(result["data"]) == 5000
|
||||
assert result["data"] == large_list
|
||||
|
||||
def test_merge_dict_list_merging_with_size_limits(self):
|
||||
def test_merge_dict_list_limits(self):
|
||||
"""Test list merging respects size limits."""
|
||||
source = {"items": list(range(3000))}
|
||||
destination = {"items": list(range(2000, 5000))} # 3000 items
|
||||
|
|
@ -921,7 +921,7 @@ class TestMergeDict:
|
|||
result = merge_dict(source, {}, max_depth=10000, max_list_size=1000000)
|
||||
assert result["a"]["b"]["c"]["data"] == "nested"
|
||||
|
||||
def test_merge_dict_limits_with_circular_reference_protection(self):
|
||||
def test_merge_dict_circular_guard(self):
|
||||
"""Test that limits work together with circular reference protection."""
|
||||
source = {"data": {}}
|
||||
source["data"]["circular"] = source # Create circular reference
|
||||
|
|
@ -1088,9 +1088,7 @@ class TestValidateUuid:
|
|||
def test_wrong_version(self):
|
||||
"""Test UUID4 against version 1 check."""
|
||||
test_uuid = str(uuid.uuid4())
|
||||
# Version check is not strict in this function - it may return True for any valid UUID
|
||||
result = validate_uuid(test_uuid, 1)
|
||||
assert isinstance(result, bool) # Just check it returns a boolean
|
||||
assert validate_uuid(test_uuid, 1) is True
|
||||
|
||||
|
||||
class TestStripNewline:
|
||||
|
|
@ -1222,14 +1220,13 @@ class TestValidateUrl:
|
|||
|
||||
def test_validate_url_basic(self):
|
||||
"""Test basic URL validation functionality."""
|
||||
# Test without actual validation due to missing yarl dependency
|
||||
# Just check the function exists and handles the missing dependency gracefully
|
||||
try:
|
||||
result = validate_url("https://example.com")
|
||||
assert isinstance(result, bool)
|
||||
except ModuleNotFoundError:
|
||||
# Expected when yarl is not installed
|
||||
assert True
|
||||
if importlib.util.find_spec("yarl") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
validate_url("https://example.com")
|
||||
return
|
||||
|
||||
result = validate_url("https://example.com", allow_internal=True)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestGetFileSidecar:
|
||||
|
|
@ -1300,32 +1297,36 @@ class TestArgConverter:
|
|||
|
||||
def test_arg_converter_basic(self):
|
||||
"""Test basic arg_converter functionality."""
|
||||
try:
|
||||
result = arg_converter("--quiet --match-filters 'duration<2min' --download-archive archive.txt")
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("quiet") is True, "quiet should be True"
|
||||
assert result.get("download_archive") == "archive.txt"
|
||||
assert "match_filter" in result, "match_filters should be in result"
|
||||
except (ModuleNotFoundError, AttributeError, ImportError):
|
||||
# Expected when yt_dlp is not available or differs
|
||||
assert True
|
||||
if importlib.util.find_spec("yt_dlp") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
arg_converter("--quiet --match-filters 'duration<2min' --download-archive archive.txt")
|
||||
return
|
||||
|
||||
result = arg_converter("--quiet --match-filters 'duration<2min' --download-archive archive.txt")
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("quiet") is True, "quiet should be True"
|
||||
assert result.get("download_archive") == "archive.txt"
|
||||
assert "match_filter" in result, "match_filters should be in result"
|
||||
|
||||
def test_arg_converter_empty_args(self):
|
||||
"""Test arg_converter with empty args."""
|
||||
try:
|
||||
result = arg_converter("")
|
||||
assert isinstance(result, dict)
|
||||
except (ModuleNotFoundError, AttributeError, ImportError):
|
||||
assert True
|
||||
if importlib.util.find_spec("yt_dlp") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
arg_converter("")
|
||||
return
|
||||
|
||||
result = arg_converter("")
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_arg_converter_replace_in_metadata(self):
|
||||
"""Test arg_converter handles replace-in-metadata without assertions."""
|
||||
try:
|
||||
result = arg_converter("--replace-in-metadata title foo bar")
|
||||
except (ModuleNotFoundError, AttributeError, ImportError):
|
||||
assert True
|
||||
if importlib.util.find_spec("yt_dlp") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
arg_converter("--replace-in-metadata title foo bar")
|
||||
return
|
||||
|
||||
result = arg_converter("--replace-in-metadata title foo bar")
|
||||
|
||||
postprocessors = result.get("postprocessors", [])
|
||||
assert postprocessors, "Expected metadata parser postprocessor to be present"
|
||||
|
||||
|
|
@ -1584,11 +1585,10 @@ class TestLoadCookies:
|
|||
|
||||
try:
|
||||
valid, jar = load_cookies(str(self.cookie_file))
|
||||
assert isinstance(valid, bool)
|
||||
assert jar is not None or not valid
|
||||
assert valid is False
|
||||
assert jar is not None
|
||||
except ValueError:
|
||||
# Expected for invalid cookie files
|
||||
assert True
|
||||
return
|
||||
|
||||
|
||||
class TestStrToDt:
|
||||
|
|
@ -1596,20 +1596,23 @@ class TestStrToDt:
|
|||
|
||||
def test_str_to_dt_basic(self):
|
||||
"""Test basic string to datetime conversion."""
|
||||
try:
|
||||
result = str_to_dt("2023-01-02 12:00:00 UTC")
|
||||
assert isinstance(result, datetime)
|
||||
except ModuleNotFoundError:
|
||||
# Expected when dateparser is not available
|
||||
assert True
|
||||
if importlib.util.find_spec("dateparser") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
str_to_dt("2023-01-02 12:00:00 UTC")
|
||||
return
|
||||
|
||||
result = str_to_dt("2023-01-02 12:00:00 UTC")
|
||||
assert isinstance(result, datetime)
|
||||
|
||||
def test_str_to_dt_relative(self):
|
||||
"""Test relative time string."""
|
||||
try:
|
||||
result = str_to_dt("1 hour ago")
|
||||
assert isinstance(result, datetime)
|
||||
except (ModuleNotFoundError, ValueError):
|
||||
assert True
|
||||
if importlib.util.find_spec("dateparser") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
str_to_dt("1 hour ago")
|
||||
return
|
||||
|
||||
result = str_to_dt("1 hour ago")
|
||||
assert isinstance(result, datetime)
|
||||
|
||||
|
||||
class TestInitClass:
|
||||
|
|
@ -1633,37 +1636,6 @@ class TestInitClass:
|
|||
assert result.unused == "default" # Should use default
|
||||
|
||||
|
||||
class TestLoadModules:
|
||||
"""Test the load_modules function."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test module structure."""
|
||||
self.temp_dir = str(make_test_temp_dir("load-modules"))
|
||||
self.root_path = Path(self.temp_dir)
|
||||
self.module_dir = self.root_path / "test_modules"
|
||||
self.module_dir.mkdir()
|
||||
|
||||
# Create test module files
|
||||
(self.module_dir / "__init__.py").write_text("")
|
||||
(self.module_dir / "test_module.py").write_text("# Test module")
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up after tests."""
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_load_modules_basic(self):
|
||||
"""Test basic module loading."""
|
||||
try:
|
||||
load_modules(self.root_path, self.module_dir)
|
||||
# Should not raise an exception
|
||||
assert True
|
||||
except Exception:
|
||||
# Module loading might fail in test environment
|
||||
assert True
|
||||
|
||||
|
||||
class TestGetChannelImages:
|
||||
"""Test the get_channel_images function."""
|
||||
|
||||
|
|
@ -1711,7 +1683,7 @@ class TestGetChannelImages:
|
|||
assert "icon" in result
|
||||
assert result["icon"] == "http://example.com/icon.jpg"
|
||||
|
||||
def test_get_channel_images_landscape_from_banner_uncropped(self):
|
||||
def test_channel_images_landscape_banner(self):
|
||||
"""Test extracting landscape from banner uncropped."""
|
||||
from app.library.Utils import get_channel_images
|
||||
|
||||
|
|
@ -1859,23 +1831,27 @@ class TestArgConverterAdvanced:
|
|||
|
||||
def test_arg_converter_with_removed_options(self):
|
||||
"""Test arg_converter with removed options tracking."""
|
||||
try:
|
||||
removed = []
|
||||
result = arg_converter("--quiet --skip-download", level=True, removed_options=removed)
|
||||
if importlib.util.find_spec("yt_dlp") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
arg_converter("--quiet --skip-download", level=True, removed_options=[])
|
||||
return
|
||||
|
||||
assert isinstance(result, dict)
|
||||
except (ModuleNotFoundError, AttributeError, ImportError):
|
||||
assert True
|
||||
removed = []
|
||||
result = arg_converter("--quiet --skip-download", level=True, removed_options=removed)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert removed
|
||||
|
||||
def test_arg_converter_dumps_enabled(self):
|
||||
"""Test arg_converter with dumps flag enabled."""
|
||||
try:
|
||||
result = arg_converter("--format best", dumps=True)
|
||||
if importlib.util.find_spec("yt_dlp") is None:
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
arg_converter("--format best", dumps=True)
|
||||
return
|
||||
|
||||
# With dumps=True, result should still be a dict with JSON-serializable content
|
||||
assert isinstance(result, (dict, list))
|
||||
except (ModuleNotFoundError, AttributeError, ImportError):
|
||||
assert True
|
||||
result = arg_converter("--format best", dumps=True)
|
||||
|
||||
assert isinstance(result, (dict, list))
|
||||
|
||||
|
||||
class TestCreateCookiesFile:
|
||||
|
|
@ -1910,7 +1886,7 @@ class TestCreateCookiesFile:
|
|||
|
||||
@patch("app.library.config.Config")
|
||||
@patch("app.library.Utils.load_cookies")
|
||||
def test_create_cookies_file_without_path(self, mock_load_cookies, mock_config):
|
||||
def test_create_cookies_file_auto_path(self, mock_load_cookies, mock_config):
|
||||
"""Test creating a cookies file without a specific path (auto temp file)."""
|
||||
from app.library.Utils import create_cookies_file
|
||||
|
||||
|
|
@ -1940,7 +1916,7 @@ class TestCreateCookiesFile:
|
|||
create_cookies_file("invalid_data", file=cookie_path)
|
||||
|
||||
@patch("app.library.Utils.load_cookies")
|
||||
def test_create_cookies_file_creates_parent_directory(self, mock_load_cookies):
|
||||
def test_create_cookies_parent_dir(self, mock_load_cookies):
|
||||
"""Test that create_cookies_file creates parent directories as needed."""
|
||||
from app.library.Utils import create_cookies_file
|
||||
|
||||
|
|
@ -1955,7 +1931,7 @@ class TestCreateCookiesFile:
|
|||
assert cookie_path.parent == Path(self.test_path / "a" / "b" / "c")
|
||||
|
||||
@patch("app.library.Utils.load_cookies")
|
||||
def test_create_cookies_file_with_special_characters(self, mock_load_cookies):
|
||||
def test_create_cookies_special_chars(self, mock_load_cookies):
|
||||
"""Test create_cookies_file with special characters in cookie data."""
|
||||
from app.library.Utils import create_cookies_file
|
||||
|
||||
|
|
@ -2099,7 +2075,7 @@ class TestRenameFile:
|
|||
assert subtitle_file.exists()
|
||||
assert conflicting_sidecar.exists()
|
||||
|
||||
def test_rename_preserves_sidecar_extensions(self, tmp_path: Path):
|
||||
def test_rename_sidecar_extensions(self, tmp_path: Path):
|
||||
"""Test that rename preserves complex sidecar extensions."""
|
||||
# Create test files with complex extensions
|
||||
test_file = tmp_path / "video.mp4"
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
describe('loadConditions', () => {
|
||||
it('loads conditions with pagination successfully', async () => {
|
||||
it('load_conditions', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -101,7 +101,7 @@ describe('useConditions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sorts conditions by priority then name', async () => {
|
||||
it('sort_priority', async () => {
|
||||
const items = [
|
||||
{ ...mockCondition, id: 1, name: 'B', priority: 2 },
|
||||
{ ...mockCondition, id: 2, name: 'A', priority: 2 },
|
||||
|
|
@ -136,7 +136,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
describe('getCondition', () => {
|
||||
it('fetches a single condition successfully', async () => {
|
||||
it('get_condition', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -157,7 +157,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
describe('createCondition', () => {
|
||||
it('creates a condition successfully', async () => {
|
||||
it('create_condition', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -183,7 +183,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
describe('updateCondition', () => {
|
||||
it('updates a condition successfully', async () => {
|
||||
it('update_condition', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -202,7 +202,7 @@ describe('useConditions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes id field from condition before sending', async () => {
|
||||
it('strip_update_id', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -216,13 +216,13 @@ describe('useConditions', () => {
|
|||
await conditions.updateCondition(1, mockCondition)
|
||||
|
||||
const requestBody = JSON.parse((requestSpy.mock.calls[0][1] as any).body)
|
||||
expect(requestBody.id).toBeUndefined()
|
||||
expect('id' in requestBody).toBe(false)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('patchCondition', () => {
|
||||
it('patches a condition successfully', async () => {
|
||||
it('patch_condition', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -242,7 +242,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
describe('deleteCondition', () => {
|
||||
it('deletes a condition successfully', async () => {
|
||||
it('delete_condition', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -265,7 +265,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
describe('testCondition', () => {
|
||||
it('tests a condition successfully', async () => {
|
||||
it('test_condition', async () => {
|
||||
const testResponse = {
|
||||
status: true,
|
||||
condition: 'duration > 60',
|
||||
|
|
@ -292,7 +292,7 @@ describe('useConditions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles test errors', async () => {
|
||||
it('store_test_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -309,7 +309,7 @@ describe('useConditions', () => {
|
|||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(conditions.lastError.value).toBeTruthy()
|
||||
expect(conditions.lastError.value).toBe('Invalid URL')
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ afterEach(() => {
|
|||
})
|
||||
|
||||
describe('useConsoleSession', () => {
|
||||
it('starts a session, persists prompt transcript, and stores streamed output', async () => {
|
||||
it('start_session', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -166,7 +166,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('restores a running session using both since and Last-Event-ID', async () => {
|
||||
it('restore_with_cursor', async () => {
|
||||
const session = useConsoleSession()
|
||||
session.state.value = {
|
||||
sessionId: 'sess-restore',
|
||||
|
|
@ -196,7 +196,6 @@ describe('useConsoleSession', () => {
|
|||
expect(fetchEventSourceMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const streamCall = fetchEventSourceMock.mock.calls[0]
|
||||
expect(streamCall).toBeDefined()
|
||||
if (!streamCall) {
|
||||
throw new Error('Expected fetchEventSource to be called once.')
|
||||
}
|
||||
|
|
@ -209,7 +208,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('marks the session expired and stops retry setup on stream not found', async () => {
|
||||
it('mark_expired_404', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -243,7 +242,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('restores a persisted session even after a local stream error state', async () => {
|
||||
it('restore_after_stream_error', async () => {
|
||||
const session = useConsoleSession()
|
||||
session.state.value = {
|
||||
sessionId: 'sess-reconnect',
|
||||
|
|
@ -277,7 +276,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('deduplicates replayed events when reconnect resumes from the last event id', async () => {
|
||||
it('dedupe_replayed_events', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -333,7 +332,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('refreshes final metadata after close so interrupted sessions keep their backend status', async () => {
|
||||
it('refresh_final_status', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -382,7 +381,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('requests cancellation for the active session without dropping local state early', async () => {
|
||||
it('cancel_without_reset', async () => {
|
||||
const session = useConsoleSession()
|
||||
session.state.value = {
|
||||
sessionId: 'sess-stop',
|
||||
|
|
@ -413,12 +412,12 @@ describe('useConsoleSession', () => {
|
|||
const deleteCall = requestSpy.mock.calls.find(
|
||||
(call) => call[0] === '/api/system/terminal/sess-stop' && call[1]?.method === 'DELETE',
|
||||
)
|
||||
expect(deleteCall).toBeDefined()
|
||||
expect(deleteCall).toEqual(['/api/system/terminal/sess-stop', { method: 'DELETE' }])
|
||||
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('disconnects the local stream without issuing a cancel request', async () => {
|
||||
it('disconnect_local', async () => {
|
||||
const session = useConsoleSession()
|
||||
session.state.value = {
|
||||
sessionId: 'sess-detach',
|
||||
|
|
@ -449,7 +448,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('refreshes metadata when cancel targets a missing session', async () => {
|
||||
it('refresh_missing_cancel', async () => {
|
||||
const session = useConsoleSession()
|
||||
session.state.value = {
|
||||
sessionId: 'sess-missing',
|
||||
|
|
@ -478,7 +477,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears visible transcript without dropping the active session cursor', () => {
|
||||
it('clear_transcript', () => {
|
||||
const session = useConsoleSession()
|
||||
session.state.value = {
|
||||
sessionId: 'sess-active',
|
||||
|
|
@ -498,7 +497,7 @@ describe('useConsoleSession', () => {
|
|||
expect(session.state.value.lastEventId).toBe('15')
|
||||
})
|
||||
|
||||
it('fetches recent sessions and filters locally hidden entries', async () => {
|
||||
it('filter_hidden_recents', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -557,7 +556,7 @@ describe('useConsoleSession', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('replays a recent session by reconnecting to its stream', async () => {
|
||||
it('replay_recent_session', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe('useDirtyCloseGuard', () => {
|
||||
it('confirms discard on route leave when the editor is open and dirty', async () => {
|
||||
it('confirm_route_leave', async () => {
|
||||
const open = ref(true);
|
||||
const dirty = ref(true);
|
||||
const onDiscard = mock(() => {});
|
||||
|
|
@ -75,7 +75,7 @@ describe('useDirtyCloseGuard', () => {
|
|||
expect(open.value).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks route updates when the user keeps editing', async () => {
|
||||
it('block_route_update', async () => {
|
||||
const open = ref(true);
|
||||
const dirty = ref(true);
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ describe('useDirtyCloseGuard', () => {
|
|||
expect(open.value).toBe(true);
|
||||
});
|
||||
|
||||
it('only prevents browser unload while the editor is open and dirty, then removes the listener on unmount', () => {
|
||||
it('guard_beforeunload', () => {
|
||||
const open = ref(true);
|
||||
const dirty = ref(true);
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ describe('useDirtyCloseGuard', () => {
|
|||
expect(afterUnmountEvent.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('dedupes concurrent close requests into one discard dialog', async () => {
|
||||
it('dedupe_close_prompt', async () => {
|
||||
const open = ref(true);
|
||||
const dirty = ref(true);
|
||||
const onDiscard = mock(() => {});
|
||||
|
|
@ -162,7 +162,7 @@ describe('useDirtyCloseGuard', () => {
|
|||
expect(open.value).toBe(false);
|
||||
});
|
||||
|
||||
it('does not guard route changes when the editor is clean', async () => {
|
||||
it('skip_clean_editor', async () => {
|
||||
const open = ref(true);
|
||||
const dirty = ref(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ describe('usePlayerShortcuts', () => {
|
|||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('toggles native text tracks and subtitle state on c', async () => {
|
||||
it('toggle_subs_c', async () => {
|
||||
const { usePlayerShortcuts } = await import('~/composables/usePlayerShortcuts');
|
||||
const addEventListenerSpy = spyOn(document, 'addEventListener');
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ describe('usePlayerShortcuts', () => {
|
|||
addEventListenerSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('closes help before closing the player on escape', async () => {
|
||||
it('close_help_first', async () => {
|
||||
const { usePlayerShortcuts } = await import('~/composables/usePlayerShortcuts');
|
||||
|
||||
const media = {
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ describe('usePlayerSubtitles', () => {
|
|||
delete (globalThis as { fetch?: typeof fetch }).fetch;
|
||||
});
|
||||
|
||||
it('loads subtitle manifest and exposes the preferred native track', async () => {
|
||||
it('load_native_track', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
|
|
@ -140,7 +140,7 @@ describe('usePlayerSubtitles', () => {
|
|||
expect(usesAssSubtitleTrack.value).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the provided full manifest path including folders', async () => {
|
||||
it('keep_manifest_path', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
|
|
@ -170,7 +170,7 @@ describe('usePlayerSubtitles', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('creates and destroys an ASS renderer for ASS subtitles when playback becomes active', async () => {
|
||||
it('mount_ass_renderer', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
|
|
@ -238,7 +238,7 @@ describe('usePlayerSubtitles', () => {
|
|||
expect(assDestroyMock.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('recreates an ASS renderer when the layout version changes without refetching subtitles', async () => {
|
||||
it('remount_on_layout_change', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const setConfigStore = (presets: Preset[]) => {
|
|||
}
|
||||
|
||||
describe('usePresetOptions', () => {
|
||||
it('groups custom presets before default presets by default', () => {
|
||||
it('group_custom_first', () => {
|
||||
setConfigStore([
|
||||
buildPreset('default_video', true),
|
||||
buildPreset('custom_audio', false),
|
||||
|
|
@ -56,7 +56,7 @@ describe('usePresetOptions', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('supports default-first grouping when requested', () => {
|
||||
it('group_default_first', () => {
|
||||
setConfigStore([
|
||||
buildPreset('default_video', true),
|
||||
buildPreset('custom_audio', false),
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ describe('usePresets', () => {
|
|||
})
|
||||
|
||||
describe('loadPresets', () => {
|
||||
it('loads presets with pagination successfully', async () => {
|
||||
it('load_presets', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -104,7 +104,7 @@ describe('usePresets', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('requests custom presets without defaults when asked', async () => {
|
||||
it('exclude_defaults', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -124,7 +124,7 @@ describe('usePresets', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sorts presets by priority then name', async () => {
|
||||
it('sort_priority', async () => {
|
||||
const items = [
|
||||
{ ...mockPreset, id: 1, name: 'B', priority: 2 },
|
||||
{ ...mockPreset, id: 2, name: 'A', priority: 2 },
|
||||
|
|
@ -159,7 +159,7 @@ describe('usePresets', () => {
|
|||
})
|
||||
|
||||
describe('getPreset', () => {
|
||||
it('fetches a single preset successfully', async () => {
|
||||
it('get_preset', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -180,7 +180,7 @@ describe('usePresets', () => {
|
|||
})
|
||||
|
||||
describe('createPreset', () => {
|
||||
it('creates a preset successfully', async () => {
|
||||
it('create_preset', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -209,7 +209,7 @@ describe('usePresets', () => {
|
|||
})
|
||||
|
||||
describe('updatePreset', () => {
|
||||
it('updates a preset successfully', async () => {
|
||||
it('update_preset', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -226,7 +226,7 @@ describe('usePresets', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('strips id from update payload', async () => {
|
||||
it('strip_update_id', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -240,14 +240,14 @@ describe('usePresets', () => {
|
|||
await presets.updatePreset(1, { ...mockPreset, default: true })
|
||||
|
||||
const requestBody = JSON.parse((requestSpy.mock.calls[0]![1] as any).body)
|
||||
expect(requestBody.id).toBeUndefined()
|
||||
expect('id' in requestBody).toBe(false)
|
||||
expect(requestBody.default).toBe(false)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('patchPreset', () => {
|
||||
it('patches a preset successfully', async () => {
|
||||
it('patch_preset', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -264,7 +264,7 @@ describe('usePresets', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('strips id and default from patch payload', async () => {
|
||||
it('sanitize_patch', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -278,14 +278,14 @@ describe('usePresets', () => {
|
|||
await presets.patchPreset(1, { id: 10, default: true })
|
||||
|
||||
const requestBody = JSON.parse((requestSpy.mock.calls[0]![1] as any).body)
|
||||
expect(requestBody.id).toBeUndefined()
|
||||
expect('id' in requestBody).toBe(false)
|
||||
expect(requestBody.default).toBe(false)
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePreset', () => {
|
||||
it('deletes a preset successfully', async () => {
|
||||
it('delete_preset', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ describe('useTaskDefinitions', () => {
|
|||
errorMock.mockClear()
|
||||
})
|
||||
|
||||
it('sorts definitions by priority then name', async () => {
|
||||
it('sort_priority', async () => {
|
||||
const items = [
|
||||
{ id: 1, name: 'B', priority: 2, updated_at: 1 },
|
||||
{ id: 2, name: 'A', priority: 2, updated_at: 2 },
|
||||
|
|
@ -90,7 +90,7 @@ describe('useTaskDefinitions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles empty payload', async () => {
|
||||
it('handle_empty_payload', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(createMockResponse({
|
||||
ok: true,
|
||||
|
|
@ -104,7 +104,7 @@ describe('useTaskDefinitions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('throws loadDefinitions error when throwInstead is true', async () => {
|
||||
it('throw_load_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(createMockResponse({
|
||||
ok: false,
|
||||
|
|
@ -117,7 +117,7 @@ describe('useTaskDefinitions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns null on getDefinition error', async () => {
|
||||
it('store_get_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(createMockResponse({
|
||||
ok: false,
|
||||
|
|
@ -132,7 +132,7 @@ describe('useTaskDefinitions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('creates definition successfully', async () => {
|
||||
it('create_definition', async () => {
|
||||
const payload: TaskDefinitionDetailed = {
|
||||
id: 2,
|
||||
name: 'New',
|
||||
|
|
@ -152,7 +152,7 @@ describe('useTaskDefinitions', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes definition on deleteDefinition', async () => {
|
||||
it('remove_deleted_definition', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(createMockResponse({
|
||||
ok: true,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('loadTasks', () => {
|
||||
it('loads tasks with pagination successfully', async () => {
|
||||
it('load_tasks', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -108,7 +108,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('sorts tasks by name A-Z', async () => {
|
||||
it('sort_name', async () => {
|
||||
const items = [
|
||||
{ ...mockTask, id: 1, name: 'Zebra' },
|
||||
{ ...mockTask, id: 2, name: 'Alpha' },
|
||||
|
|
@ -137,7 +137,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles empty tasks list', async () => {
|
||||
it('handle_empty_list', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -158,7 +158,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles errors during load', async () => {
|
||||
it('store_load_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -171,11 +171,11 @@ describe('useTasks', () => {
|
|||
const tasks = useTasks()
|
||||
await tasks.loadTasks()
|
||||
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Server error')
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('respects page and per_page parameters', async () => {
|
||||
it('pass_pagination', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -197,7 +197,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('getTask', () => {
|
||||
it('fetches a single task successfully', async () => {
|
||||
it('get_task', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -215,7 +215,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles 404 not found', async () => {
|
||||
it('store_404_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -229,13 +229,13 @@ describe('useTasks', () => {
|
|||
const result = await tasks.getTask(999)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Task not found')
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTask', () => {
|
||||
it('creates a task successfully', async () => {
|
||||
it('create_task', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -261,7 +261,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('updateTask', () => {
|
||||
it('updates a task successfully', async () => {
|
||||
it('update_task', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -280,7 +280,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes id field from task before sending', async () => {
|
||||
it('strip_update_id', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -301,7 +301,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('patchTask', () => {
|
||||
it('patches a task successfully', async () => {
|
||||
it('patch_task', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -321,7 +321,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('deleteTask', () => {
|
||||
it('deletes a task successfully', async () => {
|
||||
it('delete_task', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -344,7 +344,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('inspectTaskHandler', () => {
|
||||
it('inspects task handler successfully', async () => {
|
||||
it('inspect_handler', async () => {
|
||||
const inspectResponse: TaskInspectResponse = {
|
||||
matched: true,
|
||||
handler: 'YoutubeHandler',
|
||||
|
|
@ -383,7 +383,7 @@ describe('useTasks', () => {
|
|||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles handler not supported', async () => {
|
||||
it('handle_unsupported_handler', async () => {
|
||||
const inspectResponse: TaskInspectResponse = {
|
||||
matched: false,
|
||||
handler: null,
|
||||
|
|
@ -411,7 +411,7 @@ describe('useTasks', () => {
|
|||
fetchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles inspect errors', async () => {
|
||||
it('store_inspect_error', async () => {
|
||||
const fetchSpy = spyOn(globalThis, 'fetch')
|
||||
fetchSpy.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
|
|
@ -421,13 +421,13 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Network error')
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('markTaskItems', () => {
|
||||
it('marks all items as downloaded successfully', async () => {
|
||||
it('mark_items', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -445,7 +445,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles mark errors', async () => {
|
||||
it('store_mark_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -459,13 +459,13 @@ describe('useTasks', () => {
|
|||
const result = await tasks.markTaskItems(999)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Task not found')
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmarkTaskItems', () => {
|
||||
it('unmarks items successfully', async () => {
|
||||
it('unmark_items', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -483,7 +483,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles unmark errors', async () => {
|
||||
it('store_unmark_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -497,13 +497,13 @@ describe('useTasks', () => {
|
|||
const result = await tasks.unmarkTaskItems(999)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Task not found')
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateTaskMetadata', () => {
|
||||
it('generates metadata successfully', async () => {
|
||||
it('generate_metadata', async () => {
|
||||
const metadataResponse: TaskMetadataResponse = {
|
||||
status: 'success',
|
||||
generated: ['tvshow.nfo', 'info.json', 'poster.jpg'],
|
||||
|
|
@ -527,7 +527,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handles metadata generation errors', async () => {
|
||||
it('store_metadata_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -541,13 +541,13 @@ describe('useTasks', () => {
|
|||
const result = await tasks.generateTaskMetadata(1)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Failed to generate metadata')
|
||||
requestSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws when throwInstead is true', async () => {
|
||||
it('throw_on_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -564,7 +564,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('clears error on clearError call', async () => {
|
||||
it('clear_error', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -579,7 +579,7 @@ describe('useTasks', () => {
|
|||
|
||||
await tasks.loadTasks()
|
||||
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('Server error')
|
||||
|
||||
tasks.clearError()
|
||||
|
||||
|
|
@ -587,7 +587,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('parses API validation errors correctly', async () => {
|
||||
it('parse_validation_errors', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -611,7 +611,7 @@ describe('useTasks', () => {
|
|||
const result = await tasks.createTask(newTask)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBeTruthy()
|
||||
expect(tasks.lastError.value).toBe('name: Field required, url: Invalid URL format')
|
||||
expect(tasks.lastError.value).toContain('name: Field required')
|
||||
expect(tasks.lastError.value).toContain('url: Invalid URL format')
|
||||
requestSpy.mockRestore()
|
||||
|
|
@ -619,7 +619,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('addInProgress state', () => {
|
||||
it('sets addInProgress during create operation', async () => {
|
||||
it('set_add_in_progress', async () => {
|
||||
let inProgressDuringCall = false
|
||||
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
|
|
@ -644,7 +644,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('resets addInProgress on error', async () => {
|
||||
it('reset_add_in_progress', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -666,7 +666,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('isLoading state', () => {
|
||||
it('sets isLoading during loadTasks operation', async () => {
|
||||
it('set_loading', async () => {
|
||||
let loadingDuringCall = false
|
||||
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
|
|
@ -693,7 +693,7 @@ describe('useTasks', () => {
|
|||
})
|
||||
|
||||
describe('task list updates', () => {
|
||||
it('updates existing task in list', async () => {
|
||||
it('update_list_item', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
@ -727,7 +727,7 @@ describe('useTasks', () => {
|
|||
requestSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('removes deleted task from list', async () => {
|
||||
it('remove_list_item', async () => {
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ afterEach(async () => {
|
|||
});
|
||||
|
||||
describe('modal opacity plugin', () => {
|
||||
it('ignores a settings-only overlay', async () => {
|
||||
it('ignore_settings_overlay', async () => {
|
||||
startPlugin();
|
||||
|
||||
const settingsPanel = document.createElement('div');
|
||||
|
|
@ -77,7 +77,7 @@ describe('modal opacity plugin', () => {
|
|||
expect(syncOpacityMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores opacity when a normal overlay closes back to settings-only', async () => {
|
||||
it('restore_after_close', async () => {
|
||||
startPlugin();
|
||||
|
||||
const settingsPanel = document.createElement('div');
|
||||
|
|
@ -99,7 +99,7 @@ describe('modal opacity plugin', () => {
|
|||
expect(enableOpacityMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resyncs opacity when overlays change while already locked', async () => {
|
||||
it('resync_while_locked', async () => {
|
||||
startPlugin();
|
||||
|
||||
document.body.append(createOverlay());
|
||||
|
|
@ -112,7 +112,7 @@ describe('modal opacity plugin', () => {
|
|||
expect(syncOpacityMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not unlock opacity on beforeunload when reload is canceled', async () => {
|
||||
it('keep_lock_beforeunload', async () => {
|
||||
startPlugin();
|
||||
|
||||
document.body.append(createOverlay());
|
||||
|
|
|
|||
|
|
@ -132,81 +132,81 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe('object access helpers', () => {
|
||||
it('ag returns nested value or default value', () => {
|
||||
it('ag_nested_value', () => {
|
||||
const payload = { a: { b: { c: 42 } } };
|
||||
expect(utils.ag(payload, 'a.b.c')).toBe(42);
|
||||
expect(utils.ag(payload, 'a.b.x', 'fallback')).toBe('fallback');
|
||||
expect(utils.ag(payload, 'missing', () => 'fn-default')).toBe('fn-default');
|
||||
});
|
||||
|
||||
it('ag_set sets nested path creating objects as needed', () => {
|
||||
it('ag_set_path', () => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
utils.ag_set(payload, 'a.b.c', 99);
|
||||
expect(payload).toEqual({ a: { b: { c: 99 } } });
|
||||
});
|
||||
|
||||
it('cleanObject removes requested keys', () => {
|
||||
it('clean_object_keys', () => {
|
||||
const source = { id: 1, keep: true, drop: false };
|
||||
expect(utils.cleanObject(source, ['drop'])).toEqual({ id: 1, keep: true });
|
||||
expect(utils.cleanObject(source, [])).toEqual(source);
|
||||
});
|
||||
|
||||
it('stripPath removes base prefix and leading slashes', () => {
|
||||
it('strip_path_base', () => {
|
||||
expect(utils.stripPath('/data/downloads', '/data/downloads/video.mp4')).toBe('video.mp4');
|
||||
expect(utils.stripPath('', '/var/files/test.txt')).toBe('/var/files/test.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('string manipulation helpers', () => {
|
||||
it('r replaces tokens with context values', () => {
|
||||
it('replace_tokens', () => {
|
||||
const result = utils.r('Hello {user.name}!', { user: { name: 'YTPTube' } });
|
||||
expect(result).toBe('Hello YTPTube!');
|
||||
});
|
||||
|
||||
it('iTrim trims delimiters at requested positions', () => {
|
||||
it('itrim_edges', () => {
|
||||
expect(utils.iTrim('--value--', '-', 'both')).toBe('value');
|
||||
expect(utils.iTrim('::value', ':', 'start')).toBe('value');
|
||||
expect(utils.iTrim('value::', ':', 'end')).toBe('value');
|
||||
});
|
||||
|
||||
it('iTrim handles forward slash delimiter', () => {
|
||||
it('itrim_slash', () => {
|
||||
expect(utils.iTrim('//value//', '/', 'both')).toBe('value');
|
||||
expect(utils.iTrim('/value', '/', 'start')).toBe('value');
|
||||
expect(utils.iTrim('value/', '/', 'end')).toBe('value');
|
||||
expect(utils.iTrim('///multiple///', '/', 'both')).toBe('multiple');
|
||||
});
|
||||
|
||||
it('iTrim handles backslash delimiter', () => {
|
||||
it('itrim_backslash', () => {
|
||||
expect(utils.iTrim('\\\\value\\\\', '\\', 'both')).toBe('value');
|
||||
expect(utils.iTrim('\\value', '\\', 'start')).toBe('value');
|
||||
expect(utils.iTrim('value\\', '\\', 'end')).toBe('value');
|
||||
});
|
||||
|
||||
it('iTrim handles hyphen delimiter', () => {
|
||||
it('itrim_hyphen', () => {
|
||||
expect(utils.iTrim('--value--', '-', 'both')).toBe('value');
|
||||
expect(utils.iTrim('-value', '-', 'start')).toBe('value');
|
||||
expect(utils.iTrim('value-', '-', 'end')).toBe('value');
|
||||
expect(utils.iTrim('---multiple---', '-', 'both')).toBe('multiple');
|
||||
});
|
||||
|
||||
it('iTrim handles caret delimiter', () => {
|
||||
it('itrim_caret', () => {
|
||||
expect(utils.iTrim('^^value^^', '^', 'both')).toBe('value');
|
||||
expect(utils.iTrim('^value', '^', 'start')).toBe('value');
|
||||
expect(utils.iTrim('value^', '^', 'end')).toBe('value');
|
||||
});
|
||||
|
||||
it('iTrim handles bracket delimiters', () => {
|
||||
it('itrim_brackets', () => {
|
||||
expect(utils.iTrim('[[value]]', '[', 'both')).toBe('value]]');
|
||||
expect(utils.iTrim(']]value[[', ']', 'both')).toBe('value[[');
|
||||
});
|
||||
|
||||
it('iTrim handles dot delimiter', () => {
|
||||
it('itrim_dot', () => {
|
||||
expect(utils.iTrim('..value..', '.', 'both')).toBe('value');
|
||||
expect(utils.iTrim('.value', '.', 'start')).toBe('value');
|
||||
expect(utils.iTrim('value.', '.', 'end')).toBe('value');
|
||||
});
|
||||
|
||||
it('iTrim handles special regex characters', () => {
|
||||
it('itrim_regex_chars', () => {
|
||||
expect(utils.iTrim('**value**', '*', 'both')).toBe('value');
|
||||
expect(utils.iTrim('++value++', '+', 'both')).toBe('value');
|
||||
expect(utils.iTrim('??value??', '?', 'both')).toBe('value');
|
||||
|
|
@ -215,96 +215,96 @@ describe('string manipulation helpers', () => {
|
|||
expect(utils.iTrim('((value))', ')', 'both')).toBe('((value');
|
||||
});
|
||||
|
||||
it('iTrim handles empty string', () => {
|
||||
it('itrim_empty', () => {
|
||||
expect(utils.iTrim('', '/', 'both')).toBe('');
|
||||
});
|
||||
|
||||
it('iTrim throws error when delimiter is empty', () => {
|
||||
it('itrim_empty_delimiter', () => {
|
||||
expect(() => utils.iTrim('value', '', 'both')).toThrow('Delimiter is required');
|
||||
});
|
||||
|
||||
it('iTrim preserves middle occurrences', () => {
|
||||
it('itrim_preserve_middle', () => {
|
||||
expect(utils.iTrim('/path/to/file/', '/', 'both')).toBe('path/to/file');
|
||||
expect(utils.iTrim('//path//to//file//', '/', 'both')).toBe('path//to//file');
|
||||
});
|
||||
|
||||
it('encodePath safely encodes components', () => {
|
||||
it('encode_path', () => {
|
||||
expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4');
|
||||
});
|
||||
|
||||
it('encodePath handles % character correctly', () => {
|
||||
it('encode_percent', () => {
|
||||
expect(utils.encodePath('How to enjoy Shin Ramyun 100%.opus')).toBe(
|
||||
'How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus',
|
||||
);
|
||||
});
|
||||
|
||||
it('encodePath handles multiple special characters', () => {
|
||||
it('encode_specials', () => {
|
||||
expect(utils.encodePath('100% complete [HD] #1.mp4')).toBe(
|
||||
'100%25%20complete%20%5BHD%5D%20%231.mp4',
|
||||
);
|
||||
});
|
||||
|
||||
it('encodePath handles paths with % character', () => {
|
||||
it('keep_segments', () => {
|
||||
expect(utils.encodePath('folder/How to enjoy Shin Ramyun 100%.opus')).toBe(
|
||||
'folder/How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus',
|
||||
);
|
||||
});
|
||||
|
||||
it('encodePath handles already encoded strings', () => {
|
||||
it('preserve_encoded', () => {
|
||||
expect(utils.encodePath('How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus')).toBe(
|
||||
'How%20to%20enjoy%20Shin%20Ramyun%20100%25.opus',
|
||||
);
|
||||
});
|
||||
|
||||
it('encodePath handles mixed encoded and unencoded', () => {
|
||||
it('mix_encoded_input', () => {
|
||||
expect(utils.encodePath('folder/file%20name 100%.mp4')).toBe('folder/file%20name%20100%25.mp4');
|
||||
});
|
||||
|
||||
it('encodePath handles special characters &, =, ?', () => {
|
||||
it('encode_query_chars', () => {
|
||||
expect(utils.encodePath('query?param=value&key=100%.mp4')).toBe(
|
||||
'query%3Fparam%3Dvalue%26key%3D100%25.mp4',
|
||||
);
|
||||
});
|
||||
|
||||
it('encodePath handles empty string', () => {
|
||||
it('encode_empty', () => {
|
||||
expect(utils.encodePath('')).toBe('');
|
||||
});
|
||||
|
||||
it('encodePath handles simple filename', () => {
|
||||
it('encode_simple_filename', () => {
|
||||
expect(utils.encodePath('video.mp4')).toBe('video.mp4');
|
||||
});
|
||||
|
||||
it('encodePath handles unicode characters', () => {
|
||||
it('encode_unicode', () => {
|
||||
expect(utils.encodePath('视频文件.mp4')).toBe('%E8%A7%86%E9%A2%91%E6%96%87%E4%BB%B6.mp4');
|
||||
});
|
||||
|
||||
it('encodePath handles parentheses', () => {
|
||||
it('encode_parentheses', () => {
|
||||
expect(utils.encodePath('video (1080p).mp4')).toBe('video%20(1080p).mp4');
|
||||
});
|
||||
|
||||
it('removeANSIColors strips escape codes', () => {
|
||||
it('strip_ansi', () => {
|
||||
const sample = '\u001b[31mError\u001b[0m';
|
||||
expect(utils.removeANSIColors(sample)).toBe('Error');
|
||||
});
|
||||
|
||||
it('basename returns final segment optionally trimming extension', () => {
|
||||
it('basename_trim_ext', () => {
|
||||
expect(utils.basename('/downloads/video.mp4')).toBe('video.mp4');
|
||||
expect(utils.basename('/downloads/video.mp4', '.mp4')).toBe('video');
|
||||
expect(utils.basename('', '.mp4')).toBe('');
|
||||
});
|
||||
|
||||
it('dirname returns parent directory', () => {
|
||||
it('dirname_parent', () => {
|
||||
expect(utils.dirname('/downloads/video.mp4')).toBe('/downloads');
|
||||
expect(utils.dirname('video.mp4')).toBe('.');
|
||||
expect(utils.dirname('/file')).toBe('/');
|
||||
});
|
||||
|
||||
it('formatBytes returns human readable strings', () => {
|
||||
it('format_bytes', () => {
|
||||
expect(utils.formatBytes(0)).toBe('0 Bytes');
|
||||
expect(utils.formatBytes(1024)).toBe('1 KiB');
|
||||
});
|
||||
|
||||
it('formatTime renders hh:mm:ss or mm:ss', () => {
|
||||
it('format_time', () => {
|
||||
expect(utils.formatTime(59)).toBe('59');
|
||||
expect(utils.formatTime(90)).toBe('01:30');
|
||||
expect(utils.formatTime(3661)).toBe('01:01:01');
|
||||
|
|
@ -312,47 +312,47 @@ describe('string manipulation helpers', () => {
|
|||
});
|
||||
|
||||
describe('data conversion helpers', () => {
|
||||
it('has_data detects arrays, objects, and json strings', () => {
|
||||
it('has_data', () => {
|
||||
expect(utils.has_data({ key: 'value' })).toBe(true);
|
||||
expect(utils.has_data('""')).toBe(false);
|
||||
expect(utils.has_data('[1,2]')).toBe(true);
|
||||
expect(utils.has_data('')).toBe(false);
|
||||
});
|
||||
|
||||
it('encode and decode provide reversible transformation', () => {
|
||||
it('encode_decode_roundtrip', () => {
|
||||
const payload = { name: 'YTPTube', count: 2 };
|
||||
const encoded = utils.encode(payload);
|
||||
expect(typeof encoded).toBe('string');
|
||||
expect(utils.decode(encoded)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('getQueryParams parses query strings', () => {
|
||||
it('parse_query_params', () => {
|
||||
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' });
|
||||
});
|
||||
|
||||
it('uri prefixes runtime base path', () => {
|
||||
it('prefix_runtime_base', () => {
|
||||
runtimeConfig.app.baseURL = '/base-path';
|
||||
expect(utils.uri('/api/test')).toBe('/base-path/api/test');
|
||||
runtimeConfig.app.baseURL = '/';
|
||||
expect(utils.uri('/api/test')).toBe('/api/test');
|
||||
});
|
||||
|
||||
it('makeDownload builds expected url with folder and filename', () => {
|
||||
it('build_file_url', () => {
|
||||
runtimeConfig.app.baseURL = '/base-path';
|
||||
const url = utils.makeDownload({}, { folder: 'music', filename: 'song.mp3' });
|
||||
expect(url).toBe('/base-path/api/download/music/song.mp3');
|
||||
});
|
||||
|
||||
it('makeDownload handles m3u8 base path', () => {
|
||||
it('build_m3u8_url', () => {
|
||||
const url = utils.makeDownload({}, { filename: 'playlist' }, 'm3u8');
|
||||
expect(url).toBe('/base-path/api/player/m3u8/video/playlist.m3u8');
|
||||
});
|
||||
|
||||
it('isDownloadSkipped detects finished skipped-download items', () => {
|
||||
it('detect_download_skipped', () => {
|
||||
expect(utils.isDownloadSkipped({ status: 'finished', download_skipped: true } as any)).toBe(true);
|
||||
});
|
||||
|
||||
it('isDownloadSkipped ignores unfinished or unflagged items', () => {
|
||||
it('ignore_non_skipped', () => {
|
||||
expect(utils.isDownloadSkipped({ status: 'finished', download_skipped: false } as any)).toBe(false);
|
||||
expect(utils.isDownloadSkipped({ status: 'downloading', download_skipped: true } as any)).toBe(false);
|
||||
expect(utils.isDownloadSkipped(undefined as any)).toBe(false);
|
||||
|
|
@ -360,7 +360,7 @@ describe('data conversion helpers', () => {
|
|||
});
|
||||
|
||||
describe('dom and browser helpers', () => {
|
||||
it('copyText uses clipboard API and notifies success', async () => {
|
||||
it('write_clipboard', async () => {
|
||||
utils.copyText('sample');
|
||||
|
||||
await Promise.resolve();
|
||||
|
|
@ -372,13 +372,13 @@ describe('dom and browser helpers', () => {
|
|||
expect(notificationMock.success).toHaveBeenCalledWith('Text copied to clipboard.');
|
||||
});
|
||||
|
||||
it('disableOpacity toggles body opacity when enabled', () => {
|
||||
it('lock_opacity', () => {
|
||||
const result = utils.disableOpacity();
|
||||
expect(result).toBe(true);
|
||||
expect(document.body.style.opacity).toBe('1');
|
||||
});
|
||||
|
||||
it('disableOpacity returns false when background disabled', () => {
|
||||
it('skip_disabled_bg', () => {
|
||||
document.body.removeAttribute('style');
|
||||
storageMap.clear();
|
||||
const originalOpacity = document.body.style.opacity;
|
||||
|
|
@ -398,7 +398,7 @@ describe('dom and browser helpers', () => {
|
|||
expect(document.body.style.opacity || '').toBe(originalOpacity || '');
|
||||
});
|
||||
|
||||
it('enableOpacity applies stored opacity value', () => {
|
||||
it('restore_opacity', () => {
|
||||
utils.disableOpacity();
|
||||
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
|
||||
if (!storageMap.has(key)) {
|
||||
|
|
@ -413,7 +413,7 @@ describe('dom and browser helpers', () => {
|
|||
expect(document.body.style.opacity).toBe('0.75');
|
||||
});
|
||||
|
||||
it('syncOpacity keeps full opacity while a lock is active', () => {
|
||||
it('keep_opacity_lock', () => {
|
||||
utils.disableOpacity();
|
||||
storageMap.set('random_bg_opacity', { value: 0.35 });
|
||||
|
||||
|
|
@ -423,7 +423,7 @@ describe('dom and browser helpers', () => {
|
|||
expect(document.body.style.opacity).toBe('1');
|
||||
});
|
||||
|
||||
it('syncOpacity clears body opacity when background is disabled', () => {
|
||||
it('clear_disabled_bg', () => {
|
||||
document.body.style.opacity = '0.8';
|
||||
storageMap.set('random_bg', { value: false });
|
||||
|
||||
|
|
@ -435,7 +435,7 @@ describe('dom and browser helpers', () => {
|
|||
});
|
||||
|
||||
describe('network and id helpers', () => {
|
||||
it('request prefixes relative urls and sets defaults', async () => {
|
||||
it('prefix_base_url', async () => {
|
||||
const responseMock = { status: 200 } as Response;
|
||||
fetchMock.mockResolvedValue(responseMock);
|
||||
|
||||
|
|
@ -452,7 +452,7 @@ describe('network and id helpers', () => {
|
|||
expect((options as Record<string, unknown>).withCredentials).toBe(true);
|
||||
});
|
||||
|
||||
it('convertCliOptions posts payload and returns parsed json', async () => {
|
||||
it('post_cli_args', async () => {
|
||||
const jsonMock = mock().mockResolvedValue({ success: true });
|
||||
const responseMock = { status: 200, json: jsonMock };
|
||||
fetchMock.mockResolvedValue(responseMock);
|
||||
|
|
@ -468,7 +468,7 @@ describe('network and id helpers', () => {
|
|||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('convertCliOptions throws on non-200 response', async () => {
|
||||
it('throw_cli_error', async () => {
|
||||
const jsonMock = mock().mockResolvedValue({ error: 'fail' });
|
||||
const responseMock = { status: 400, json: jsonMock };
|
||||
fetchMock.mockResolvedValue(responseMock);
|
||||
|
|
@ -479,13 +479,13 @@ describe('network and id helpers', () => {
|
|||
});
|
||||
|
||||
describe('async helpers', () => {
|
||||
it('awaiter resolves when test becomes truthy', async () => {
|
||||
it('resolve_truthy', async () => {
|
||||
const values = [false, false, 'done'];
|
||||
const result = await utils.awaiter(() => values.shift(), 500, 0.01);
|
||||
expect(result).toBe('done');
|
||||
});
|
||||
|
||||
it('awaiter returns false when timeout reached', async () => {
|
||||
it('timeout', async () => {
|
||||
const result = await utils.awaiter(() => false, 50, 0.01);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from '~/utils/sidebarSwipe';
|
||||
|
||||
describe('sidebarSwipe', () => {
|
||||
it('detects apple mobile webkit navigators', () => {
|
||||
it('detect_apple_touch', () => {
|
||||
expect(
|
||||
isAppleMobileTouchNavigator({
|
||||
userAgent:
|
||||
|
|
@ -38,7 +38,7 @@ describe('sidebarSwipe', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reserves the ios navigation edge and starts the sidebar swipe just inside it', () => {
|
||||
it('reserve_ios_edge', () => {
|
||||
const nav = {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 Version/18.4 Mobile/15E148 Safari/604.1',
|
||||
|
|
@ -54,7 +54,7 @@ describe('sidebarSwipe', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('keeps the original left-edge open band on non-apple mobile browsers', () => {
|
||||
it('keep_default_edge', () => {
|
||||
const nav = {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/147.0.0.0 Mobile Safari/537.36',
|
||||
|
|
@ -67,7 +67,7 @@ describe('sidebarSwipe', () => {
|
|||
expect(canStartSidebarOpenSwipe(MOBILE_SIDEBAR_EDGE_WIDTH + 1, nav)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns close while the sidebar is already open', () => {
|
||||
it('return_close', () => {
|
||||
expect(getSidebarSwipeMode(true, 4)).toBe('close');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ function normalize(filters: string[]): Set<string> {
|
|||
}
|
||||
|
||||
describe("MatchFilterParser", () => {
|
||||
it("handles simple AND", () => {
|
||||
it("handle_simple_and", () => {
|
||||
const parser = new MatchFilterParser("filesize>1000000 & duration<600");
|
||||
|
||||
expect(parser.evaluate({ filesize: 2_000_000, duration: 200 })).toBe(true);
|
||||
|
|
@ -14,7 +14,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(parser.evaluate({ filesize: 2_000_000, duration: 800 })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles OR", () => {
|
||||
it("handle_or", () => {
|
||||
const parser = new MatchFilterParser("uploader='BBC' || uploader='NHK'");
|
||||
|
||||
expect(parser.evaluate({ uploader: "BBC" })).toBe(true);
|
||||
|
|
@ -22,7 +22,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(parser.evaluate({ uploader: "CNN" })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles grouping", () => {
|
||||
it("handle_grouping", () => {
|
||||
const parser = new MatchFilterParser("(filesize>1000000 & duration<600) || uploader='BBC'");
|
||||
|
||||
expect(parser.evaluate({ filesize: 2_000_000, duration: 200 })).toBe(true);
|
||||
|
|
@ -30,14 +30,14 @@ describe("MatchFilterParser", () => {
|
|||
expect(parser.evaluate({ filesize: 500_000, duration: 200, uploader: "CNN" })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles unary presence", () => {
|
||||
it("handle_unary_presence", () => {
|
||||
expect(new MatchFilterParser("duration").evaluate({ duration: 100 })).toBe(true);
|
||||
expect(new MatchFilterParser("duration").evaluate({})).toBe(false);
|
||||
expect(new MatchFilterParser("!duration").evaluate({})).toBe(true);
|
||||
expect(new MatchFilterParser("!duration").evaluate({ duration: 100 })).toBe(false);
|
||||
});
|
||||
|
||||
it("parses duration with numeric values", () => {
|
||||
it("parse_duration", () => {
|
||||
expect(new MatchFilterParser("duration<120").evaluate({ duration: 30 })).toBe(true);
|
||||
expect(new MatchFilterParser("duration<120").evaluate({ duration: 200 })).toBe(false);
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(new MatchFilterParser("duration<3600").evaluate({ duration: 3700 })).toBe(false);
|
||||
});
|
||||
|
||||
it("parses filesize with numeric values", () => {
|
||||
it("parse_filesize", () => {
|
||||
expect(new MatchFilterParser("filesize>1000000").evaluate({ filesize: 2_000_000 })).toBe(true);
|
||||
expect(new MatchFilterParser("filesize>1000000").evaluate({ filesize: 500_000 })).toBe(false);
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(new MatchFilterParser("filesize>=1073741824").evaluate({ filesize: 1000000 })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles string operators", () => {
|
||||
it("handle_string_operators", () => {
|
||||
const d = { uploader: "BBC News Channel" };
|
||||
|
||||
expect(new MatchFilterParser("uploader*='News'").evaluate(d)).toBe(true);
|
||||
|
|
@ -70,7 +70,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(new MatchFilterParser("uploader~='News\\s+Network'").evaluate(d)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles spaces around operators", () => {
|
||||
it("handle_operator_spaces", () => {
|
||||
const d = {
|
||||
"channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w",
|
||||
"uploader": "BBC"
|
||||
|
|
@ -85,7 +85,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(new MatchFilterParser("channel_id = 'UCfmrcEdes7yDtEISGPM1T-A' & availability = subscriber_only").evaluate(d)).toBe(false);
|
||||
});
|
||||
|
||||
it("reproduces original bug report", () => {
|
||||
it("reproduce_bug_report", () => {
|
||||
const testData = {
|
||||
"age_limit": 0,
|
||||
"comment_count": 6,
|
||||
|
|
@ -102,7 +102,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(new MatchFilterParser("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'").evaluate(testData)).toBe(true);
|
||||
});
|
||||
|
||||
it("tests OR operator precedence", () => {
|
||||
it("or_precedence", () => {
|
||||
const testData = {
|
||||
"age_limit": 0,
|
||||
"fps": 120,
|
||||
|
|
@ -134,7 +134,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(new MatchFilterParser(expr2).evaluate(testDataEdge)).toBe(true);
|
||||
});
|
||||
|
||||
it("tests complex OR precedence scenarios", () => {
|
||||
it("handle_chained_or", () => {
|
||||
const testDataOrOnly = {
|
||||
"age_limit": 1,
|
||||
"fps": 60,
|
||||
|
|
@ -163,7 +163,7 @@ describe("MatchFilterParser", () => {
|
|||
expect(new MatchFilterParser(exprChain).evaluate(testDataOnlyOr)).toBe(true);
|
||||
});
|
||||
|
||||
it("exports filters correctly", () => {
|
||||
it("export_filters", () => {
|
||||
const simpleParser = new MatchFilterParser("filesize>1000000 & duration<600");
|
||||
const simpleExported = simpleParser.export();
|
||||
expect(simpleExported).toEqual(["filesize>1000000&duration<600"]);
|
||||
|
|
@ -177,12 +177,6 @@ describe("MatchFilterParser", () => {
|
|||
expect(normalize(complexExported)).toEqual(normalize(["filesize>1000000&duration<600", "uploader='BBC'"]));
|
||||
|
||||
const testData = { filesize: 2000000, duration: 300 };
|
||||
for (const filter of complexExported) {
|
||||
const filterParser = new MatchFilterParser(filter);
|
||||
if (filterParser.evaluate(testData)) {
|
||||
expect(true).toBe(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(complexExported.some((filter) => new MatchFilterParser(filter).evaluate(testData))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue