From 843a014928daa5efdba34186ebc592c0cac15684 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 20 Oct 2026 16:54:54 +0200 Subject: [PATCH 01/10] =?UTF-8?q?perf(backend):=20=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E3=80=81gzip=E3=80=81=E9=9D=99=E6=80=81?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=A4=B4=E3=80=81=E9=83=A8=E5=88=86=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E5=91=BD=E4=B8=AD=E3=80=81TMDB=20=E8=B4=9F=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E4=B8=8E=20LRU=E3=80=81=E9=95=9C=E5=83=8F=E9=A2=84?= =?UTF-8?q?=E7=BC=96=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 日志:basicConfig 加 force=True,避免 mcp 先导入后日志文件为空 - GZipMiddleware(minimum_size=1000),SSE 默认排除 - SPA 依赖为 /assets/ 加 immutable,入口 HTML 加 no-cache - rename_operation 活动过滤改为 NOT IN,命中 ux_rename_operation_active_target - TMDB 缓存:否定与回退结果 6 小时 TTL,命中时 move_to_end - Dockerfile 构建阶段预编译字节码 --- Dockerfile | 5 +- backend/src/main.py | 17 +++- backend/src/module/conf/log.py | 3 + .../src/module/database/rename_operation.py | 2 +- .../src/module/parser/analyser/tmdb_parser.py | 42 +++++--- backend/src/test/test_conf_log.py | 21 ++++ .../test/test_database_rename_operation.py | 26 +++++ backend/src/test/test_http_perf.py | 99 +++++++++++++++++++ backend/src/test/test_tmdb.py | 43 ++++++++ 9 files changed, 239 insertions(+), 19 deletions(-) create mode 100644 backend/src/test/test_http_perf.py diff --git a/Dockerfile b/Dockerfile index 1996a876..2b2d5299 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,9 @@ FROM ghcr.io/astral-sh/uv:0.13-python3.14-alpine AS builder WORKDIR /app -ENV UV_LINK_MODE=copy +# 依赖与应用源码都预编译为 .pyc,容器冷启动不再逐个编译 +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 # Install dependencies (cached layer) COPY backend/pyproject.toml backend/uv.lock ./ @@ -11,6 +13,7 @@ RUN uv sync --frozen --no-dev # Copy application source COPY backend/src ./src +RUN .venv/bin/python -m compileall -q src FROM python:3.14-alpine AS runtime diff --git a/backend/src/main.py b/backend/src/main.py index 1d56c447..df1992e0 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -4,8 +4,9 @@ from contextlib import asynccontextmanager from pathlib import Path import uvicorn -from fastapi import APIRouter, Depends, FastAPI, Response +from fastapi import APIRouter, Depends, FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from starlette.routing import Route @@ -67,6 +68,9 @@ def create_app() -> FastAPI: allow_headers=["*"], ) + # SSE(text/event-stream)由中间件默认排除,不会被压缩缓冲 + app.add_middleware(GZipMiddleware, minimum_size=1000) + # mount routers app.include_router(v1, prefix="/api") @@ -99,14 +103,21 @@ def posters(path: str): # 前端插件组件与宿主同源(第 3.8 节),只允许加载本站脚本:远程脚本与内联脚本 # 都被拒绝。只加在前端静态文件上,/docs 的 Swagger 页面依赖内联脚本与 CDN。 -def _spa_csp(response: Response) -> None: +def _spa_headers(request: Request, response: Response) -> None: response.headers["Content-Security-Policy"] = "script-src 'self'" + # /assets/ 下的文件名带内容哈希,可永久缓存;入口 HTML 必须每次校验, + # 否则会引用已不存在的旧哈希文件。fonts/、images/ 等不带哈希,不设置。 + path = request.url.path + if path.startswith("/assets/"): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + elif path in ("/", "/index.html"): + response.headers["Cache-Control"] = "no-cache" def mount_webui(app: FastAPI, dist: Path) -> None: # 前端路由优先级最低,只在没有路径操作或挂载匹配时生效;浏览器导航 # (Accept: text/html)到不存在的路径时回落到 index.html,其它请求返回 404 - web = APIRouter(dependencies=[Depends(_spa_csp)]) + web = APIRouter(dependencies=[Depends(_spa_headers)]) web.frontend("/", directory=dist) app.include_router(web) diff --git a/backend/src/module/conf/log.py b/backend/src/module/conf/log.py index 3df327a6..76f32836 100644 --- a/backend/src/module/conf/log.py +++ b/backend/src/module/conf/log.py @@ -86,6 +86,9 @@ def setup_logger(level: int = logging.INFO, reset: bool = False): logging.basicConfig( level=level, handlers=[queue_handler], + # 先导入的第三方库(如 mcp)可能已让根日志器带上处理器, + # 此时 basicConfig 会静默跳过,日志文件就收不到任何内容 + force=True, ) # 压低 HTTP 请求日志:httpx2 是本项目的客户端;google-genai 与 mcp 仍依赖旧 httpx diff --git a/backend/src/module/database/rename_operation.py b/backend/src/module/database/rename_operation.py index f44e2311..9a6a24e2 100644 --- a/backend/src/module/database/rename_operation.py +++ b/backend/src/module/database/rename_operation.py @@ -63,7 +63,7 @@ class RenameOperationDatabase: RenameOperation.target_path == target_path, ) if active_only: - statement = statement.where(RenameOperation.state != "done") + statement = statement.where(col(RenameOperation.state).not_in(["done"])) statement = statement.order_by( # type: ignore[assignment] col(RenameOperation.updated_at).desc() ) diff --git a/backend/src/module/parser/analyser/tmdb_parser.py b/backend/src/module/parser/analyser/tmdb_parser.py index 83359339..d676889f 100644 --- a/backend/src/module/parser/analyser/tmdb_parser.py +++ b/backend/src/module/parser/analyser/tmdb_parser.py @@ -26,7 +26,11 @@ def _api_key() -> str: # In-memory cache for TMDB lookups to avoid repeated API calls _TMDB_CACHE_MAX = 512 -_tmdb_cache: OrderedDict[str, "TMDBInfo | None"] = OrderedDict() +# 否定/回退结果(无匹配、回退到电影)的存活时间:既避免对同一个查不到的标题 +# 反复请求 TMDB,又不让一次临时故障把标题永久毒化 +_TMDB_NEGATIVE_TTL = 6 * 3600.0 +# 值为 (过期的 monotonic 时刻, 结果);inf 表示不过期 +_tmdb_cache: OrderedDict[str, "tuple[float, TMDBInfo | None]"] = OrderedDict() def reset_cache() -> None: @@ -234,15 +238,32 @@ async def tmdb_parser( # URL instead of a locally-saved one, so mixing the two would poison # whichever caller queries second. cache_key = f"{title}:{language}:{test}:{is_movie}" - if cache_key in _tmdb_cache: - return _tmdb_cache[cache_key] + hit = _tmdb_cache.get(cache_key) + if hit is not None: + if hit[0] > time.monotonic(): + _tmdb_cache.move_to_end(cache_key) + return hit[1] + del _tmdb_cache[cache_key] + result = await _tmdb_lookup(title, language, test, is_movie) + # 剧集结果的 season 非空;None 与电影回退(season=[])都按否定结果限时缓存 + expires = ( + time.monotonic() + _TMDB_NEGATIVE_TTL + if result is None or not result.season + else float("inf") + ) + _tmdb_cache[cache_key] = (expires, result) + _tmdb_cache.move_to_end(cache_key) + if len(_tmdb_cache) > _TMDB_CACHE_MAX: + _tmdb_cache.popitem(last=False) + return result + + +async def _tmdb_lookup(title, language, test: bool, is_movie: bool) -> TMDBInfo | None: async with RequestContent() as req: if is_movie: # 已知是电影/剧场版,直接查询 search/movie,跳过剧集搜索 - result = await _search_movie(title, language, req) - _tmdb_cache[cache_key] = result - return result + return await _search_movie(title, language, req) url = search_url(title, language) contents = await req.get_json(url) if not contents: @@ -266,9 +287,7 @@ async def tmdb_parser( matched_id = cid break if matched_id is None: - # search/tv 有结果但都不是动画:回退到 search/movie (剧场版等)。 - # Don't cache the negative result permanently — a temporary - # TMDB hiccup shouldn't poison this title for the process lifetime. + # search/tv 有结果但都不是动画:回退到 search/movie (剧场版等) return await _search_movie(title, language, req) url_info = info_url(matched_id, language) info_content = await req.get_json(url_info) @@ -355,13 +374,8 @@ async def tmdb_parser( virtual_season_starts if virtual_season_starts else None ), ) - if len(_tmdb_cache) >= _TMDB_CACHE_MAX: - _tmdb_cache.popitem(last=False) - _tmdb_cache[cache_key] = result return result else: - # No results at all — don't cache the negative result permanently, - # see the matched_id is None case above. return None diff --git a/backend/src/test/test_conf_log.py b/backend/src/test/test_conf_log.py index 72607c83..1494f914 100644 --- a/backend/src/test/test_conf_log.py +++ b/backend/src/test/test_conf_log.py @@ -1,5 +1,8 @@ """启动时日志轮转(rotate_boot_log):历史进备份而非被删除。""" +import os +import subprocess +import sys from pathlib import Path import pytest @@ -66,3 +69,21 @@ class TestRotateBootLog: conf_log.rotate_boot_log() assert log_path.read_text() == "content" + + +def test_setup_logger_replaces_handlers_installed_by_earlier_imports(tmp_path: Path): + """生产导入顺序里 mcp 早于 setup_logger 导入;根日志器已有处理器时 + basicConfig 不能被静默跳过,日志必须落到 data/log.txt。""" + code = ( + "import logging, main\n" + "assert [type(h).__name__ for h in logging.getLogger().handlers]" + " == ['QueueHandler']\n" + "logging.getLogger('probe').info('probe-marker')\n" + ) + src = Path(__file__).resolve().parents[1] + env = {**os.environ, "PYTHONPATH": str(src)} + subprocess.run( + [sys.executable, "-c", code], cwd=tmp_path, env=env, check=True, timeout=60 + ) + # 子进程退出时 atexit 会排空日志队列 + assert "probe-marker" in (tmp_path / "data" / "log.txt").read_text() diff --git a/backend/src/test/test_database_rename_operation.py b/backend/src/test/test_database_rename_operation.py index ffcf8864..32c59b1c 100644 --- a/backend/src/test/test_database_rename_operation.py +++ b/backend/src/test/test_database_rename_operation.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone from typing import Any import pytest +from sqlalchemy import text from sqlalchemy.exc import IntegrityError from module.database import Database @@ -268,3 +269,28 @@ async def test_database_facade_exposes_rename_operation_repository(db_engine): row, created = await db.rename_operation.get_or_create(_operation()) assert created is True assert await db.rename_operation.get(row.id) is not None + + +async def test_get_by_target_active_filter_uses_partial_unique_index(db_session): + """state 过滤必须写成 NOT IN ('done'),才能命中同样条件的部分唯一索引。""" + repo = RenameOperationDatabase(db_session) + captured: list[Any] = [] + original = db_session.execute + + async def spy(statement, *args, **kwargs): + captured.append(statement) + return await original(statement, *args, **kwargs) + + db_session.execute = spy # type: ignore[method-assign] + await repo.get_by_target( + downloader_type="qbittorrent", save_path="/d", target_path="t.mp4" + ) + db_session.execute = original # type: ignore[method-assign] + + plan = await db_session.execute( + text( + "EXPLAIN QUERY PLAN " + + str(captured[0].compile(compile_kwargs={"literal_binds": True})) + ) + ) + assert "ux_rename_operation_active_target" in " ".join(r[3] for r in plan) diff --git a/backend/src/test/test_http_perf.py b/backend/src/test/test_http_perf.py new file mode 100644 index 00000000..8f369220 --- /dev/null +++ b/backend/src/test/test_http_perf.py @@ -0,0 +1,99 @@ +"""HTTP 层性能头:gzip 压缩与 SPA 静态文件的缓存策略。""" + +import asyncio +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +import main +from module.api.deps import get_context +from module.security.api import get_current_user + +HTML = {"accept": "text/html"} +GZIP = {"accept-encoding": "gzip"} +IMMUTABLE = "public, max-age=31536000, immutable" + + +@pytest.fixture +def app(tmp_path): + dist = tmp_path / "dist" + for sub in ("assets", "fonts", "images"): + (dist / sub).mkdir(parents=True) + (dist / "index.html").write_text("spa") + (dist / "theme-init.js").write_text("// theme") + (dist / "assets" / "app-abc.js").write_text("export const x = 1;\n" * 200) + (dist / "fonts" / "a.woff2").write_text("font") + (dist / "images" / "a.svg").write_text("") + app = main.create_app() + main.mount_webui(app, dist) + return app + + +class TestSpaCacheControl: + @pytest.mark.parametrize( + ("path", "cache_control"), + [ + ("assets/app-abc.js", IMMUTABLE), + ("", "no-cache"), + ("index.html", "no-cache"), + ("fonts/a.woff2", None), + ("images/a.svg", None), + ("theme-init.js", None), + ], + ) + def test_cache_control_by_path(self, app, path, cache_control): + resp = TestClient(app).get(f"/{path}", headers=HTML) + assert resp.status_code == 200 + assert resp.headers.get("cache-control") == cache_control + + +class TestGzip: + def test_large_static_asset_is_gzipped(self, app): + resp = TestClient(app).get("/assets/app-abc.js", headers=GZIP) + assert resp.headers["content-encoding"] == "gzip" + + def test_small_response_is_not_gzipped(self, app): + resp = TestClient(app).get("/", headers={**HTML, **GZIP}) + assert "content-encoding" not in resp.headers + + def test_event_stream_is_not_gzipped(self, app): + app.dependency_overrides[get_current_user] = lambda: "user" + app.dependency_overrides[get_context] = lambda: MagicMock( + is_running=True, first_run=False + ) + started: dict = {} + + async def drive() -> None: + scope = { + "type": "http", + "method": "GET", + "path": "/api/v1/events/stream", + "raw_path": b"/api/v1/events/stream", + "query_string": b"", + "headers": [(b"accept-encoding", b"gzip")], + "scheme": "http", + "server": ("test", 80), + "client": ("test", 1), + "http_version": "1.1", + "root_path": "", + } + + async def receive() -> dict: + await asyncio.Event().wait() # 客户端保持连接,不发 disconnect + return {} + + async def send(message: dict) -> None: + if message["type"] == "http.response.start": + started.update(message) + raise asyncio.CancelledError # 只取响应头,随后断开 + + try: + await app(scope, receive, send) + except asyncio.CancelledError: + pass + + asyncio.run(drive()) + headers = {k.decode(): v.decode() for k, v in started["headers"]} + assert headers["content-type"].startswith("text/event-stream") + assert "content-encoding" not in headers diff --git a/backend/src/test/test_tmdb.py b/backend/src/test/test_tmdb.py index 45dbbcb1..c4aedb24 100644 --- a/backend/src/test/test_tmdb.py +++ b/backend/src/test/test_tmdb.py @@ -318,3 +318,46 @@ async def test_tmdb_parser_live(): assert tmdb_info.title == "冰海战记" assert tmdb_info.year == bangumi_year assert tmdb_info.last_season == bangumi_season + + +async def test_tmdb_parser_negative_result_is_cached_until_ttl_expires(mocker): + """查不到的标题在 TTL 内只请求一次,过期后重新请求。""" + requests: list[str] = [] + + async def fake_get_json(url: str) -> dict: + requests.append(url) + return {"results": []} + + mocker.patch.object( + tmdb_parser_module.RequestContent, "get_json", side_effect=fake_get_json + ) + now = mocker.patch.object(tmdb_parser_module.time, "monotonic", return_value=0.0) + tmdb_parser_module._tmdb_cache.clear() + + assert await tmdb_parser("不存在", "zh", test=True) is None + first = len(requests) + assert await tmdb_parser("不存在", "zh", test=True) is None + assert len(requests) == first + + now.return_value = tmdb_parser_module._TMDB_NEGATIVE_TTL + 1 + assert await tmdb_parser("不存在", "zh", test=True) is None + assert len(requests) == 2 * first + + +async def test_tmdb_parser_cache_hit_refreshes_lru_order(mocker): + """命中的条目移到队尾,满员淘汰的是最久未用而非最早写入的条目。""" + mocker.patch.object(tmdb_parser_module, "_TMDB_CACHE_MAX", 2) + mocker.patch.object( + tmdb_parser_module.RequestContent, "get_json", side_effect=_fake_get_json + ) + tmdb_parser_module._tmdb_cache.clear() + + await tmdb_parser("A", "zh", test=True) + await tmdb_parser("B", "zh", test=True) + await tmdb_parser("A", "zh", test=True) # 命中,A 变为最近使用 + await tmdb_parser("C", "zh", test=True) # 淘汰 B + + assert list(tmdb_parser_module._tmdb_cache) == [ + "A:zh:True:False", + "C:zh:True:False", + ] From 76ec949bc1727c08a4c068d2b0fbec82c3953984 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Tue, 20 Oct 2026 17:01:46 +0200 Subject: [PATCH 02/10] =?UTF-8?q?perf(webui):=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=99=A8=E9=A1=B5=E6=94=B9=E4=B8=BA=E5=8D=95=E4=B8=AA=E8=99=9A?= =?UTF-8?q?=E6=8B=9F=E6=BB=9A=E5=8A=A8=E8=A1=A8=EF=BC=8C=E5=88=86=E7=BB=84?= =?UTF-8?q?=E4=BD=9C=E4=B8=BA=E5=8F=AF=E6=8A=98=E5=8F=A0=E7=9A=84=E8=A1=A8?= =?UTF-8?q?=E5=A4=B4=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按保存路径分组的多个 ab-fold-panel + NDataTable 合并为一个 virtual-scroll + flex-height 的 NDataTable, 只渲染可见行;表格不再放在 NCollapseItem 里,SSE 推送的新数据能刷新行 - 分组以树形父行显示(名称与数量),复选框级联勾选整组,可折叠,默认展开 - 选中项改为 Set(shallowRef),成员判断 O(1);移除未使用的 toggleKey/toggleGroup - 名称列用 CSS 省略号 + title,不再逐行测量宽度 20k 种子(prod 构建):就绪 15.1 s → 0.14 s,堆 3031 MB → 19 MB,KeepAlive 驱逐后重挂载不再崩溃 --- webui/src/pages/index/downloader.test.ts | 158 ++++++++++++++++ webui/src/pages/index/downloader.vue | 184 +++++++++++++------ webui/src/store/__tests__/downloader.test.ts | 8 +- webui/src/store/downloader.ts | 33 +--- 4 files changed, 290 insertions(+), 93 deletions(-) create mode 100644 webui/src/pages/index/downloader.test.ts diff --git a/webui/src/pages/index/downloader.test.ts b/webui/src/pages/index/downloader.test.ts new file mode 100644 index 00000000..b62fbe1c --- /dev/null +++ b/webui/src/pages/index/downloader.test.ts @@ -0,0 +1,158 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { ref } from 'vue'; +import type { Component } from 'vue'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useConfigStore } from '@/store/config'; +import { useDownloaderStore } from '@/store/downloader'; +import { mockConfig } from '@/test/mocks/api'; +import type { Config } from '#/config'; +import type { QbTorrentInfo } from '#/downloader'; + +// happy-dom 没有布局:给虚拟列表一个固定视口高度,否则一行都不渲染。 +// 必须在 naive-ui(vueuc 的 ResizeObserver 单例)加载前替换 +vi.hoisted(() => { + class FixedResizeObserver { + callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe(target: Element) { + const entry = { target, contentRect: { width: 1000, height: 600 } }; + this.callback( + [entry as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver + ); + } + + unobserve() {} + disconnect() {} + } + globalThis.ResizeObserver = FixedResizeObserver; +}); + +const downloaderData = ref(null); + +vi.mock('@/hooks/useEventStream', () => ({ + useEventStream: () => ({ connected: ref(true), downloaderData }), +})); + +vi.mock('@/utils/axios', () => ({ + axios: { get: vi.fn(), post: vi.fn() }, +})); + +vi.mock('@/api/downloader', () => ({ + apiDownloader: { + getTorrents: vi.fn().mockResolvedValue([]), + instances: vi.fn().mockResolvedValue({ default: '', instances: [] }), + }, +})); + +vi.mock('@/hooks/useMyI18n', () => ({ + useMyI18n: () => ({ + t: (key: string) => key, + returnUserLangMsg: () => '', + }), +})); + +vi.mock('@/hooks/useMessage', () => ({ + useMessage: () => ({ success: vi.fn(), error: vi.fn() }), +})); + +function torrent( + hash: string, + savePath: string, + progress = 0.5 +): QbTorrentInfo { + return { + hash, + name: `torrent-${hash}`, + size: 1024, + progress, + dlspeed: 0, + upspeed: 0, + num_seeds: 0, + num_leechs: 0, + state: 'downloading', + eta: 0, + category: 'Bangumi', + save_path: savePath, + added_on: 0, + downloader_id: 'default', + }; +} + +describe('Downloader page', () => { + let DownloaderPage: Component; + + beforeAll(async () => { + vi.stubGlobal('definePage', vi.fn()); + // vitest 的自动导入不含 @vueuse/core + vi.stubGlobal('useIntervalFn', () => ({ pause: vi.fn(), resume: vi.fn() })); + DownloaderPage = (await import('./downloader.vue')).default; + }); + + beforeEach(() => { + setActivePinia(createPinia()); + useConfigStore().config = mockConfig as unknown as Config; + downloaderData.value = null; + }); + + async function mountPage() { + const wrapper = mount(DownloaderPage, { + attachTo: document.body, + global: { stubs: { 'ab-button': true, RouterLink: true } }, + }); + // 首帧在挂载后到达,与真实 SSE 一致(store 的 watch 不是 immediate) + downloaderData.value = [ + torrent('a1', '/dl/Show A'), + torrent('a2', '/dl/Show A'), + torrent('b1', '/dl/Show B'), + ]; + await flushPromises(); + return wrapper; + } + + function rowOf(wrapper: Awaited>, text: string) { + const row = wrapper + .findAll('.n-data-table-tbody .n-data-table-tr') + .find((r) => r.text().includes(text)); + if (!row) throw new Error(`row not found: ${text}`); + return row; + } + + it('should update the row when the store receives a new SSE payload', async () => { + const wrapper = await mountPage(); + expect(rowOf(wrapper, 'torrent-a1').text()).toContain('50%'); + + downloaderData.value = [torrent('a1', '/dl/Show A', 1)]; + await flushPromises(); + + expect(rowOf(wrapper, 'torrent-a1').text()).toContain('100%'); + }); + + it('should select every torrent of the group when its header checkbox is checked', async () => { + const wrapper = await mountPage(); + + await rowOf(wrapper, 'Show A (2)').find('.n-checkbox').trigger('click'); + + expect([...useDownloaderStore().selectedKeys].sort()).toEqual([ + 'default:a1', + 'default:a2', + ]); + expect(wrapper.find('.action-bar-count').text()).toContain('2'); + }); + + it('should hide the group rows when the group is collapsed', async () => { + const wrapper = await mountPage(); + + await rowOf(wrapper, 'Show A (2)') + .find('.n-data-table-expand-trigger') + .trigger('click'); + + const text = wrapper.find('.n-data-table-tbody').text(); + expect(text).not.toContain('torrent-a1'); + expect(text).toContain('torrent-b1'); + }); +}); diff --git a/webui/src/pages/index/downloader.vue b/webui/src/pages/index/downloader.vue index 04ca376d..b4e9e200 100644 --- a/webui/src/pages/index/downloader.vue +++ b/webui/src/pages/index/downloader.vue @@ -1,5 +1,10 @@