From 211052f0e38f3c1ed0127abaad6284f76829caac Mon Sep 17 00:00:00 2001 From: GitHub Date: Tue, 1 Sep 2026 22:56:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(e2e):=20Playwright=20E2E=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=B5=8B=E8=AF=95=E5=9F=BA=E5=BB=BA=20=E2=80=94=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=90=88=E6=88=90=E6=95=B0=E6=8D=AE=20mock?= =?UTF-8?q?=20=E6=A8=A1=E5=BC=8F=20+=205=20=E7=94=A8=E4=BE=8B=20+=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/e2e_mock.py:EASY_TDX_E2E_MOCK=1 时 lifespan 替换 TDX/MAC 客户端为 确定性合成数据(CRC32 播种随机游走,分页语义对齐真实 /bars);回测/WF/ 评估/自选/策略库仍走真实后端,SSE 由 QuoteStreamer 真轮询(mock 下 2s 一拍) - web-ui:@playwright/test + playwright.config.ts(webServer 自动起 serve, EASY_TDX_CONFIG_DIR 指向每轮临时目录);e2e/ 5 用例覆盖看板五指数/SSE、 自选增删、回测全流程、附加分析(WF 柱状图+一条龙评估卡)、策略库保存 - package.json 加 test:e2e;CI frontend job 追加 E2E;verify_ci.sh 补前端段 - tests/unit/test_e2e_mock.py(11 例)守护 mock 契约;本地 npx playwright test 全绿 --- .github/workflows/ci.yml | 13 + .gitignore | 5 + CHANGELOG.md | 6 + scripts/verify_ci.sh | 27 +- src/easy_tdx/web/app.py | 51 +++- src/easy_tdx/web/e2e_mock.py | 467 ++++++++++++++++++++++++++++++++++ tests/unit/test_e2e_mock.py | 170 +++++++++++++ web-ui/e2e/README.md | 57 +++++ web-ui/e2e/backtest.spec.ts | 54 ++++ web-ui/e2e/dashboard.spec.ts | 23 ++ web-ui/e2e/strategies.spec.ts | 25 ++ web-ui/e2e/watchlist.spec.ts | 24 ++ web-ui/package-lock.json | 64 +++++ web-ui/package.json | 4 +- web-ui/playwright.config.ts | 70 +++++ 15 files changed, 1042 insertions(+), 18 deletions(-) create mode 100644 src/easy_tdx/web/e2e_mock.py create mode 100644 tests/unit/test_e2e_mock.py create mode 100644 web-ui/e2e/README.md create mode 100644 web-ui/e2e/backtest.spec.ts create mode 100644 web-ui/e2e/dashboard.spec.ts create mode 100644 web-ui/e2e/strategies.spec.ts create mode 100644 web-ui/e2e/watchlist.spec.ts create mode 100644 web-ui/playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 912b245..9086b96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,3 +76,16 @@ jobs: working-directory: web-ui - run: npm run build working-directory: web-ui + # Playwright E2E(mock 模式):serve 托管刚构建的 dist,行情全部来自 + # EASY_TDX_E2E_MOCK=1 合成数据,不连真实通达信服务器、不受交易时段限制。 + # 需要 Python 侧 easy-tdx[web](webServer 用系统 python 启动 serve)。 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install -e ".[web]" + - run: npx playwright install --with-deps chromium + working-directory: web-ui + - run: npm run test:e2e + working-directory: web-ui + env: + EASY_TDX_PYTHON: python diff --git a/.gitignore b/.gitignore index 5a24e38..04f0f0c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ CLAUDE.md # 代码审计报告(本地产物,不入库) audit-report-*.html + +# Playwright E2E 运行产物(trace/截图/视频)与临时配置目录 +web-ui/e2e/.results/ +web-ui/test-results/ +web-ui/playwright-report/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a819711..6d097b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [未发布] + +### 新增 + +- **Playwright E2E 前端测试基建**(升级计划 P4-1)——web-ui 引入 `@playwright/test`(`e2e/` + `playwright.config.ts`,`npm run test:e2e`)。**mock 方案选后端合成数据而非 page.route 拦截**:`EASY_TDX_E2E_MOCK=1` 时 serve 的 lifespan 把 TDX/MAC 客户端替换为合成数据客户端(`web/e2e_mock.py`,按 (market, code) CRC32 播种的确定性随机游走,分页语义与真实 /bars 一致),回测/WF/一条龙评估/自选/策略库继续走**真实后端代码**(它们本就不依赖行情连接),SSE 由 QuoteStreamer 真轮询合成数据全链路覆盖(mock 模式下轮询降到 2s 一拍,不受交易时段限制)。用例覆盖:看板五大指数区块+SSE 价格渲染、自选增删、回测全流程(净值图/绩效表/成交记录)、「附加分析」开关(WF 逐窗柱状图+一条龙评估卡)、策略库保存;`EASY_TDX_CONFIG_DIR` 指向每轮独立临时目录(断言可写死、不污染真实 `~/.easy_tdx`)。CI frontend job 追加 E2E 步骤;`verify_ci.sh` 补 `--no-frontend` 与前端 typecheck+build+E2E 段。新增 `tests/unit/test_e2e_mock.py`(11 例)守护 mock 与真实客户端的契约。 + ## [1.27.0] — 2026-09-01 **公式与轮动版本**——升级计划 P3 + P4(部分)落地:通达信公式解析器让写惯公式的用户零 Python 进入筛选/回测,轮动组合引擎补齐「排名换仓」组合形态,附 Docker 部署与一键门禁脚本。 diff --git a/scripts/verify_ci.sh b/scripts/verify_ci.sh index 7367812..4945631 100644 --- a/scripts/verify_ci.sh +++ b/scripts/verify_ci.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# easy-tdx 一键本地门禁(等价 CI 的质量检查,v1.27 新增)。 +# easy-tdx 一键本地门禁(等价 CI 的质量检查,v1.27 新增;v1.28 补前端 E2E)。 # -# 用法:bash scripts/verify_ci.sh [--fast] -# --fast 跳过全量测试(只跑 ruff + mypy + 格式检查) +# 用法:bash scripts/verify_ci.sh [--fast] [--no-frontend] +# --fast 跳过全量测试与前端(只跑 ruff + mypy + 格式检查) +# --no-frontend 跳过前端 typecheck+build+E2E(只跑 Python 侧) # # 可选安装为 git hook(pre-push): # ln -s ../../scripts/verify_ci.sh .git/hooks/pre-push @@ -19,7 +20,13 @@ if [ ! -f "$PY" ]; then fi FAST=0 -[ "${1:-}" = "--fast" ] && FAST=1 +NO_FRONTEND=0 +for arg in "$@"; do + case "$arg" in + --fast) FAST=1 ;; + --no-frontend) NO_FRONTEND=1 ;; + esac +done echo "── ruff check ──────────────────────────────────────────" "$PY" -m ruff check src/ tests/ @@ -31,7 +38,7 @@ echo "── mypy --strict ───────────────── "$PY" -m mypy src/easy_tdx/ if [ "$FAST" = "1" ]; then - echo "── 跳过测试(--fast)───────────────────────────────────" + echo "── 跳过测试与前端(--fast)─────────────────────────────" echo "✓ verify_ci (fast) 全部通过" exit 0 fi @@ -39,5 +46,15 @@ fi echo "── pytest(全量单元测试)────────────────────────────────" "$PY" -m pytest tests/ -q --ignore=tests/integration +if [ "$NO_FRONTEND" = "0" ] && [ -d web-ui/node_modules ]; then + echo "── 前端 typecheck + build ──────────────────────────────" + (cd web-ui && npm run build) + + echo "── Playwright E2E(mock 模式,无需真实行情)────────────" + (cd web-ui && npm run test:e2e) +else + echo "── 跳过前端(--no-frontend 或 web-ui/node_modules 缺失)─" +fi + echo "" echo "✓ verify_ci 全部通过" diff --git a/src/easy_tdx/web/app.py b/src/easy_tdx/web/app.py index 12082d5..a717696 100644 --- a/src/easy_tdx/web/app.py +++ b/src/easy_tdx/web/app.py @@ -63,24 +63,40 @@ def _resolve_web_dist_dir() -> Path | None: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: - """管理 TDX 连接生命周期:启动时连接,关闭时断开。""" - from easy_tdx.client import AsyncTdxClient + """管理 TDX 连接生命周期:启动时连接,关闭时断开。 + + E2E mock 模式(``EASY_TDX_E2E_MOCK=1``,见 :mod:`easy_tdx.web.e2e_mock`): + 全部行情连接替换为合成数据客户端(不连真实服务器、不受交易时段限制), + 回测 / 自选 / 策略库等纯计算路径保持真实,供 Playwright E2E 使用。 + """ + from easy_tdx.web.e2e_mock import is_e2e_mock_enabled, log_mock_banner + + mock_mode = is_e2e_mock_enabled() # --- 标准 TDX 客户端 --- host = app.state.tdx_host port = app.state.tdx_port timeout = app.state.tdx_timeout - client = AsyncTdxClient(host=host, port=port, timeout=timeout) - try: - await client.connect() - logger.info("TDX client connected to %s:%s", host, port) - except Exception: - logger.warning("TDX client connection failed — endpoints will return 503") + client: Any + if mock_mode: + from easy_tdx.web.e2e_mock import MockTdxClient + + log_mock_banner() + client = MockTdxClient() + else: + from easy_tdx.client import AsyncTdxClient + + client = AsyncTdxClient(host=host, port=port, timeout=timeout) + try: + await client.connect() + logger.info("TDX client connected to %s:%s", host, port) + except Exception: + logger.warning("TDX client connection failed — endpoints will return 503") app.state.tdx_client = client - # --- 实时行情 SSE 推送器(共享轮询 + fan-out) --- + # --- 实时行情 SSE 推送器(共享轮询 + fan-out;mock 模式轮询合成数据) --- try: from easy_tdx.models.enums import Market from easy_tdx.web.quote_streamer import QuoteStreamer @@ -92,7 +108,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # SQLite 存 "SH"/"SZ"/"BJ" 字符串,轮询器需要 Market 枚举 return [(Market[mkt], code) for mkt, code in store.symbols()] - streamer = QuoteStreamer(client.get_security_quotes, _watch_symbols) + # mock 模式不受真实限流/交易时段约束,缩短轮询间隔让 E2E 的 + # SSE 断言(首帧价格渲染)秒级到达,而不是等盘外 60s 慢拍 + streamer = QuoteStreamer( + client.get_security_quotes, + _watch_symbols, + trading_interval=2.0 if mock_mode else 8.0, + idle_interval=2.0 if mock_mode else 60.0, + ) streamer.start() app.state.quote_streamer = streamer except Exception: @@ -100,9 +123,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.state.quote_streamer = None # --- MAC 协议客户端 --- - mac_client = None + mac_client: Any = None enable_mac = getattr(app.state, "enable_mac", True) - if enable_mac: + if mock_mode: + from easy_tdx.web.e2e_mock import MockMacClient + + mac_client = MockMacClient() + elif enable_mac: try: from easy_tdx.mac.client import AsyncMacClient diff --git a/src/easy_tdx/web/e2e_mock.py b/src/easy_tdx/web/e2e_mock.py new file mode 100644 index 0000000..89b14ec --- /dev/null +++ b/src/easy_tdx/web/e2e_mock.py @@ -0,0 +1,467 @@ +"""E2E 合成行情数据源(``EASY_TDX_E2E_MOCK=1`` 时由 serve 激活)。 + +**为什么在 FastAPI 层 mock 而不是用 Playwright ``page.route`` 拦截**: + +1. 回测 / WF / 一条龙评估 / 自选 / 策略库全部走**真实后端代码路径**(纯计算 + + SQLite CRUD,本来就不依赖行情连接),E2E 能捕获后端 schema 变更;route 拦截 + 的静态 JSON 会与后端 schema 漂移,测试通过不代表系统可用。 +2. SSE(``/stream/quotes``,EventSource)无法用 ``page.route`` 稳定 mock(需要 + 流式 body);mock 客户端让 QuoteStreamer 真正轮询合成数据,前端 SSE 链路 + (连接→首帧→快照渲染)也被覆盖。 +3. 任务型端点(提交→task_id→轮询→done)在 route 拦截里要手写状态机,mock + 客户端天然支持。 + +代价:本模块必须与真实客户端的方法签名/返回列保持一致——由 +``tests/unit/test_e2e_mock.py`` 与 E2E 套件本身共同守护。 + +数据特征: + +- **确定性**:每个 (market, code) 用 CRC32 做随机种子,同一进程内多次调用、 + 不同机器上跑 E2E,行情完全一致(回测结果可复现、断言可写死)。 +- **锚定今天**:K 线序列以「今天」为最新一根(bdate_range),与前端默认日期 + 范围(开始 2020-01-06 ~ 结束今天)自然咬合。 +- **分页语义**与真实 /bars 一致:``start=0`` 返回最新 ``count`` 根(页内升序), + ``start`` 递增向更早翻页(前端 fetchBars 依赖此语义拼接)。 +""" + +from __future__ import annotations + +import logging +import zlib +from datetime import datetime, timedelta +from typing import Any + +import numpy as np +import pandas as pd + +logger = logging.getLogger(__name__) + +__all__ = ["MockTdxClient", "MockMacClient", "E2E_MOCK_ENV"] + +#: 激活环境变量名(serve 启动时读取,见 web/app.py lifespan)。 +E2E_MOCK_ENV = "EASY_TDX_E2E_MOCK" + +# 合成 K 线总根数(约 10.4 年日线)。前端默认区间 2020-01-06 ~ 今天约 1660 根, +# 翻页上限 10×800;2600 根既覆盖默认区间,又让第 3 页就翻到数据起点。 +_HISTORY_BARS = 2600 + +# 已知代码 → 中文名(提升 E2E 可读性;未命中用「股票XXXXXX」兜底)。 +_KNOWN_NAMES: dict[str, str] = { + "600519": "贵州茅台", + "000001": "平安银行", + "603986": "兆易创新", + "600000": "浦发银行", + "300750": "宁德时代", + "000001|SH": "上证指数", + "399001": "深证成指", + "399006": "创业板指", + "000688": "科创50", + "000300": "沪深300", +} + + +def _market_str(market: Any) -> str: + """任意市场表示(枚举/int/字符串)→ 规范字符串 SZ/SH/BJ。""" + mapping = { + "0": "SZ", + "1": "SH", + "2": "BJ", + "MARKET.SZ": "SZ", + "MARKET.SH": "SH", + "MARKET.BJ": "BJ", + } + s = str(market).upper() + return mapping.get(s, s) + + +def _seed(market: Any, code: str) -> int: + """(market, code) → 确定性随机种子(CRC32)。""" + return zlib.crc32(f"{_market_str(market)}{code}".encode()) + + +def _display_name(market: Any, code: str) -> str: + """代码 → 中文名。仅 SH 的 000001 有歧义(平安银行 vs 上证指数),用 + ``code|SH`` 键消歧;其余市场直接按代码查。""" + if _market_str(market) == "SH" and f"{code}|SH" in _KNOWN_NAMES: + return _KNOWN_NAMES[f"{code}|SH"] + return _KNOWN_NAMES.get(code, f"股票{code}") + + +def _synth_closes(market: Any, code: str, n: int) -> np.ndarray: + """生成 n 根确定性随机游走收盘价(指数基准价位 5~50 元)。""" + rng = np.random.default_rng(_seed(market, code)) + base = 5.0 + rng.uniform(0.0, 45.0) + rets = rng.normal(0.0004, 0.018, n) + return base * np.cumprod(1.0 + rets) + + +def _synth_ohlcv(market: Any, code: str, n: int) -> pd.DataFrame: + """生成 n 根升序 OHLCV 日线(datetime 为 pd.Timestamp,MAC 契约)。""" + rng = np.random.default_rng(_seed(market, code)) + close = _synth_closes(market, code, n) + open_ = np.empty(n) + open_[0] = close[0] + open_[1:] = close[:-1] * (1.0 + rng.normal(0.0, 0.004, n - 1)) + high = np.maximum(open_, close) * (1.0 + np.abs(rng.normal(0.0, 0.006, n))) + low = np.minimum(open_, close) * (1.0 - np.abs(rng.normal(0.0, 0.006, n))) + vol = rng.integers(50_000, 5_000_000, n).astype(np.float64) + amount = vol * close * 100.0 + dates = pd.bdate_range(end=pd.Timestamp.now().normalize(), periods=n) + return pd.DataFrame( + { + "datetime": dates, + "open": open_, + "high": high, + "low": low, + "close": close, + "vol": vol, + "amount": amount, + } + ) + + +def _page_bars(full: pd.DataFrame, start: int, count: int) -> pd.DataFrame: + """按真实 /bars 分页语义切片:start=0 → 最新 count 根(页内升序)。""" + n = len(full) + lo = max(0, n - start - count) + hi = n - start + if hi <= 0 or lo >= n: + return full.iloc[0:0] + return full.iloc[lo:hi] + + +def _quote_dict(mkt_enum: Any, code: str) -> dict[str, Any]: + """单标的五档快照(列集合对齐 quote_streamer._QUOTE_FIELDS 白名单)。""" + closes = _synth_closes(market_enum_key(mkt_enum), code, 30) + price = float(closes[-1]) + pre_close = float(closes[-2]) + rng = np.random.default_rng(_seed(market_enum_key(mkt_enum), code) ^ 0xBEEF) + bid1 = round(price - 0.02, 2) + ask1 = round(price + 0.02, 2) + return { + "market": mkt_enum, + "code": code, + "price": price, + "pre_close": pre_close, + "open": float(closes[-1]), + "high": round(price * 1.01, 2), + "low": round(price * 0.99, 2), + "vol": float(rng.integers(10_000, 900_000)), + "cur_vol": float(rng.integers(10, 900)), + "amount": price * float(rng.integers(10_000, 900_000)) * 100.0, + "s_vol": float(rng.integers(1000, 9000)), + "b_vol": float(rng.integers(1000, 9000)), + "rise_speed": round(float(rng.uniform(-1, 1)), 2), + "limit_up": round(pre_close * 1.1, 2), + "limit_down": round(pre_close * 0.9, 2), + "decimal_point": 2, + "server_time": "10:30:00", + "trading_status": 0, + **{f"bid{i}": round(bid1 - 0.01 * (i - 1), 2) for i in range(1, 6)}, + **{f"ask{i}": round(ask1 + 0.01 * (i - 1), 2) for i in range(1, 6)}, + **{f"bid_vol{i}": float(rng.integers(10, 500)) for i in range(1, 6)}, + **{f"ask_vol{i}": float(rng.integers(10, 500)) for i in range(1, 6)}, + } + + +def market_enum_key(mkt_enum: Any) -> str: + """Market 枚举 → 种子用的字符串键(SZ/SH/BJ)。""" + from easy_tdx.models.enums import Market + + return {Market.SZ: "SZ", Market.SH: "SH", Market.BJ: "BJ"}.get(mkt_enum, str(mkt_enum)) + + +def _int_market_to_enum(value: int) -> Any: + """int 市场 → Market 枚举(MAC 客户端约定 int)。""" + from easy_tdx.models.enums import Market + + return {0: Market.SZ, 1: Market.SH, 2: Market.BJ}.get(int(value), Market.SZ) + + +class MockTdxClient: + """AsyncTdxClient 的合成数据替身(覆盖 web 路由用到的方法)。""" + + async def close(self) -> None: + """lifespan 关闭时调用(无真实连接,空操作)。""" + + async def get_security_bars( + self, + market: Any, + code: str, + category: Any, + start: int = 0, + count: int = 800, + *, + bar_time: str = "start", + ) -> pd.DataFrame: + """个股 K 线(/bars 回退路径,MAC 可用时不会走到)。""" + df = _page_bars(_synth_ohlcv(market_enum_key(market), code, _HISTORY_BARS), start, count) + daily = _is_daily_category(category) + return _bars_to_legacy_cols(df, daily) + + async def get_index_bars( + self, + market: Any, + code: str, + category: Any, + start: int = 0, + count: int = 800, + *, + bar_time: str = "start", + ) -> pd.DataFrame: + """指数 K 线(/bars/index,看板迷你 K 线与情绪雷达数据源)。""" + df = _page_bars(_synth_ohlcv(market_enum_key(market), code, _HISTORY_BARS), start, count) + return _bars_to_legacy_cols(df, _is_daily_category(category)) + + async def get_minute_time_data(self, market: Any, code: str) -> pd.DataFrame: + """今日分时(240 点:价格围绕昨收随机游走 + 每分钟量)。""" + rng = np.random.default_rng(_seed(market_enum_key(market), code) ^ 0xCAFE) + pre_close = float(_synth_closes(market_enum_key(market), code, 30)[-2]) + prices = pre_close * np.cumprod(1.0 + rng.normal(0.0, 0.0015, 240)) + t0 = datetime.now().replace(hour=9, minute=30, second=0, microsecond=0) + times = [t0 + timedelta(minutes=i) for i in range(240)] + return pd.DataFrame( + { + "datetime": [t.strftime("%H:%M") for t in times], + "price": prices, + "vol": rng.integers(100, 9000, 240).astype(float), + } + ) + + async def get_security_quotes(self, stocks: list[tuple[Any, str]]) -> pd.DataFrame: + """批量五档快照(POST /quotes + QuoteStreamer SSE 共用)。""" + return pd.DataFrame([_quote_dict(m, c) for m, c in stocks]) + + async def get_market_stat(self) -> pd.DataFrame: + """全市场涨跌统计(看板「市场统计」卡,单行 df)。""" + rng = np.random.default_rng(20260901) + up = int(rng.integers(1800, 2800)) + down = int(rng.integers(1800, 2800)) + return pd.DataFrame( + [ + { + "up_count": up, + "down_count": down, + "neutral_count": int(rng.integers(100, 300)), + "suspended_count": int(rng.integers(10, 60)), + "total_count": up + down + 300, + "total_amount": float(rng.uniform(7e11, 1.1e12)), + "total_volume": float(rng.uniform(6e11, 9e11)), + "total_market_cap": float(rng.uniform(6e13, 8.5e13)), + "limit_up_count": int(rng.integers(30, 80)), + "limit_down_count": int(rng.integers(5, 40)), + } + ] + ) + + async def get_transaction_data( + self, market: Any, code: str, start: int = 0, count: int = 800 + ) -> pd.DataFrame: + """当日逐笔(个股弹窗用;简化为合成 tick 序列)。""" + rng = np.random.default_rng(_seed(market_enum_key(market), code) ^ 0x7777) + closes = _synth_closes(market_enum_key(market), code, 30) + n = max(1, min(count, 200)) + return pd.DataFrame( + { + "time": [f"10:{i % 60:02d}" for i in range(n)], + "price": closes[-1] * (1.0 + rng.normal(0.0, 0.002, n)), + "vol": rng.integers(1, 500, n).astype(float), + "buyorsell": rng.integers(0, 2, n).astype(int), + } + ) + + +class MockMacClient: + """AsyncMacClient 的合成数据替身(覆盖 web 路由用到的方法)。""" + + async def close(self) -> None: + """lifespan 关闭时调用(无真实连接,空操作)。""" + + async def get_stock_kline( + self, + market: Any, + code: str, + period: Any, + start: int = 0, + count: int = 800, + times: int = 1, + *, + adjust: Any = None, + bar_time: str = "start", + ) -> pd.DataFrame: + """个股 K 线(/bars 主路径)。market 为 int(MAC 协议约定)。 + + 返回 MAC 契约列:datetime(含 00:00 时分)+ OHLC + vol/amount + + float_shares(会被 bars 路由的 _normalize_mac_df 丢弃/规整)。 + """ + df = _page_bars( + _synth_ohlcv(market_enum_key(_int_market_to_enum(int(market))), code, _HISTORY_BARS), + start, + count, + ) + out = df.copy() + out["float_shares"] = 1.5e9 + return out + + async def get_symbol_info(self, *, market: Any, code: str) -> pd.DataFrame: + """证券名称快照(/mac/symbol-info,自选补名用)。""" + return pd.DataFrame( + [{"market": int(market), "code": code, "name": _display_name(market, code)}] + ) + + async def get_stock_quotes_list( + self, + *, + category: Any = None, + start: int = 0, + count: int = 80, + sort_type: Any = None, + sort_order: Any = None, + exclude_flags: Any = None, + ) -> pd.DataFrame: + """排行行情(/mac/quote-list,看板涨跌榜/分布懒加载)。 + + 固定 40 只合成标的,按 close/pre_close 排序返回 count 根(封顶 200, + 避免分布懒加载的 3000 只请求生成过大 df)。 + """ + rng = np.random.default_rng(0xE2E5) + n = 40 + codes = [f"6{i:05d}" for i in range(n // 2)] + [f"0{i:05d}" for i in range(n - n // 2)] + rows = [] + for i, code in enumerate(codes): + closes = _synth_closes("SH" if code.startswith("6") else "SZ", code, 30) + rows.append( + { + "market": 1 if code.startswith("6") else 0, + "code": code, + "name": _display_name("SH" if code.startswith("6") else "SZ", code), + "close": float(closes[-1]), + "pre_close": float(closes[-2]), + "vol": float(rng.integers(10_000, 900_000)), + "amount": float(closes[-1]) * float(rng.integers(10_000, 900_000)), + "turnover_rate": round(float(rng.uniform(0.1, 25.0)), 2), + } + ) + df = pd.DataFrame(rows) + df["_pct"] = df["close"] / df["pre_close"] - 1.0 + df = df.sort_values("_pct", ascending=(str(sort_order).upper() == "ASC")) + df = df.drop(columns=["_pct"]).iloc[start : start + min(count, 200)] + return df.reset_index(drop=True) + + async def get_board_list( + self, *, board_type: Any = None, count: int = 500, sort_column: Any = None + ) -> pd.DataFrame: + """板块列表(/board-mac/list,看板行业/概念热度榜)。""" + rng = np.random.default_rng(0xB0AD) + names = [ + "银行", + "证券", + "半导体", + "白酒", + "新能源车", + "光伏", + "军工", + "医药", + "房地产", + "煤炭", + "钢铁", + "传媒", + "计算机", + "通信", + "家电", + "食品饮料", + ] + rows = [] + for i, name in enumerate(names): + closes = _synth_closes("BOARD", name, 30) + rows.append( + { + "code": f"8810{i % 10}{i:02d}", + "name": name, + "price": float(closes[-1]), + "pre_close": float(closes[-2]), + "sort_value": float(rng.uniform(-5, 5)), + } + ) + df = pd.DataFrame(rows) + df["_pct"] = df["price"] / df["pre_close"] - 1.0 + df = df.sort_values("_pct", ascending=False).drop(columns=["_pct"]) + return df.head(min(count, len(df))).reset_index(drop=True) + + async def get_board_members( + self, *, board_symbol: str, count: int = 100, sort_type: Any = None, sort_order: Any = None + ) -> pd.DataFrame: + """板块成分股(板块弹窗)。""" + return await self.get_stock_quotes_list( + count=count, sort_type=sort_type, sort_order=sort_order + ) + + async def get_belong_board(self, *, market: Any, code: str) -> pd.DataFrame: + """个股所属板块(个股弹窗)。""" + return pd.DataFrame( + [ + {"code": "881001", "name": "银行"}, + {"code": "881101", "name": "上证主力"}, + ] + ) + + async def get_unusual( + self, *, market: Any = None, start: int = 0, count: int = 50 + ) -> pd.DataFrame: + """市场异动流(看板异动雷达)。""" + rng = np.random.default_rng(_seed("UNUSUAL", str(market)) ^ 0x5EED) + descs = ["火箭发射", "大笔买入", "封涨停板", "打开跌停板", "快速反弹", "有大买盘"] + rows = [] + for i in range(min(count, 12)): + mkt_int = int(market) if market is not None else 1 + prefix = "6" if mkt_int == 1 else "0" # SH→6 开头,SZ→0 开头 + code = f"{prefix}{int(rng.integers(0, 99999)):05d}" + hh = 9 + int(i / 12 * 6) + mm = int(rng.integers(0, 60)) + rows.append( + { + "time": f"{hh:02d}:{mm:02d}:{int(rng.integers(0, 60)):02d}", + "code": code, + "name": _display_name(market, code), + "desc": str(rng.choice(descs)), + "value": f"+{rng.uniform(2, 11):.1f}%", + } + ) + return pd.DataFrame(rows) + + +# ── 内部辅助 ───────────────────────────────────────────────────────────────── + + +def _is_daily_category(category: Any) -> bool: + """日线及以上周期 → True(复用 bars 路由的判定口径)。""" + from easy_tdx._df import _category_to_minutes + + return _category_to_minutes(int(category)) is None + + +def _bars_to_legacy_cols(df: pd.DataFrame, daily: bool) -> pd.DataFrame: + """把内部 datetime OHLCV 规整为旧 /bars 契约(日线 date / 分钟 datetime)。""" + if df.empty: + return df + out = df.copy() + col = "date" if daily else "datetime" + if daily: + out["datetime"] = pd.to_datetime(out["datetime"]).dt.strftime("%Y-%m-%d") + else: + out["datetime"] = pd.to_datetime(out["datetime"]).dt.strftime("%Y-%m-%d %H:%M:%S") + return out.rename(columns={"datetime": col}) + + +def is_e2e_mock_enabled() -> bool: + """当前进程是否处于 E2E mock 模式。""" + import os + + return os.environ.get(E2E_MOCK_ENV) == "1" + + +def log_mock_banner() -> None: + """serve 启动时打一行显式提示(避免误把 mock 数据当真实行情)。""" + logger.warning( + "[E2E-MOCK] EASY_TDX_E2E_MOCK=1 — 行情接口返回合成数据(仅限 Playwright E2E 使用)" + ) diff --git a/tests/unit/test_e2e_mock.py b/tests/unit/test_e2e_mock.py new file mode 100644 index 0000000..3a9d63b --- /dev/null +++ b/tests/unit/test_e2e_mock.py @@ -0,0 +1,170 @@ +"""E2E 合成数据源(web/e2e_mock.py)单元测试。 + +守护两件事: +1. mock 客户端与真实客户端的**契约**(方法签名可用、返回列覆盖前端字段); +2. 数据的**确定性与分页语义**(E2E 断言可复现的前提)。 + +另有一个 TestClient 端到端用例:EASY_TDX_E2E_MOCK=1 下 /bars、/market/stat、 +/mac/quote-list 返回合成数据(不连真实服务器)。 +""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from easy_tdx.models.enums import Market +from easy_tdx.web.e2e_mock import ( + E2E_MOCK_ENV, + MockMacClient, + MockTdxClient, + _page_bars, + _synth_ohlcv, +) + +pytest.importorskip("fastapi") + + +# ── 数据生成内核 ───────────────────────────────────────────────────────────── + + +def test_synth_ohlcv_deterministic() -> None: + """同一 (market, code) 两次生成结果逐位一致(E2E 断言可复现的前提)。""" + a = _synth_ohlcv("SH", "600519", 300) + b = _synth_ohlcv("SH", "600519", 300) + pd.testing.assert_frame_equal(a, b) + assert list(a.columns) == ["datetime", "open", "high", "low", "close", "vol", "amount"] + + +def test_synth_ohlcv_differs_across_symbols() -> None: + """不同标的应有不同行情(避免看板五指数全长得一样)。""" + a = _synth_ohlcv("SH", "000001", 100)["close"].iloc[-1] + b = _synth_ohlcv("SZ", "000001", 100)["close"].iloc[-1] + assert a != b + + +def test_page_bars_newest_first_page() -> None: + """分页语义与真实 /bars 一致:start=0 取最新 count 根,页内升序。""" + df = _synth_ohlcv("SH", "600519", 1000) + page0 = _page_bars(df, 0, 800) + assert len(page0) == 800 + assert page0["datetime"].iloc[-1] == df["datetime"].iloc[-1] # 最新一根在页尾 + assert page0["datetime"].is_monotonic_increasing + + page1 = _page_bars(df, 800, 800) + assert len(page1) == 200 + assert page1["datetime"].iloc[-1] < page0["datetime"].iloc[0] # 更早一段 + + assert len(_page_bars(df, 5000, 800)) == 0 # 越界翻页返回空 + + +# ── Mock 客户端契约 ────────────────────────────────────────────────────────── + + +async def test_mock_tdx_quotes_contract() -> None: + """quotes df:market 列是 Market 枚举(QuoteStreamer._df_to_dicts 依赖)、 + 字段覆盖 SSE 白名单(前端行情表 + 看板指数卡)。""" + client = MockTdxClient() + df = await client.get_security_quotes([(Market.SH, "000001"), (Market.SZ, "000001")]) + assert len(df) == 2 + # pandas 会把 IntEnum 列统一为 int64(真实客户端同样如此); + # QuoteStreamer._df_to_dicts 依赖 IntEnum 哈希相等完成 int → "SH" 映射 + assert df["market"].iloc[0] == Market.SH + from easy_tdx.web.quote_streamer import _MARKET_NAMES + + assert _MARKET_NAMES.get(df["market"].iloc[0]) == "SH" + for col in ("price", "pre_close", "open", "high", "low", "vol", "amount", "bid1", "ask_vol5"): + assert col in df.columns + # 指数与个股同名代码(SH000001 上证指数 / SZ000001 平安银行)行情不同 + assert df["price"].iloc[0] != df["price"].iloc[1] + + +async def test_mock_tdx_bars_daily_date_column() -> None: + """日线返回 date 列(旧 /bars 契约),分页 start/count 生效。""" + client = MockTdxClient() + df = await client.get_security_bars(Market.SH, "600519", 4, 0, 50) # 4 = DAY + assert "date" in df.columns and "datetime" not in df.columns + assert len(df) == 50 + assert df["date"].is_monotonic_increasing + + +async def test_mock_tdx_minute_and_stat() -> None: + """分时 240 点 + 市场统计单行(看板两块数据源)。""" + client = MockTdxClient() + minute = await client.get_minute_time_data(Market.SH, "000001") + assert len(minute) == 240 + assert {"datetime", "price", "vol"} <= set(minute.columns) + + stat = await client.get_market_stat() + assert len(stat) == 1 + for col in ("up_count", "down_count", "total_count", "total_amount"): + assert col in stat.columns + + +async def test_mock_mac_kline_contract() -> None: + """MAC get_stock_kline:datetime 列 + float_shares(bars 路由规整依赖)。""" + client = MockMacClient() + df = await client.get_stock_kline(1, "600519", 4, 0, 30, 1, adjust=None) + assert "datetime" in df.columns + assert "float_shares" in df.columns + assert len(df) == 30 + + +async def test_mock_mac_quote_list_sort_and_columns() -> None: + """排行行情:涨跌幅排序生效、列覆盖前端 RankRow 渲染需求。""" + client = MockMacClient() + desc = await client.get_stock_quotes_list(count=20, sort_order="DESC") + asc = await client.get_stock_quotes_list(count=20, sort_order="ASC") + assert len(desc) == 20 + for col in ("market", "code", "name", "close", "pre_close", "amount"): + assert col in desc.columns + d_pct = desc["close"] / desc["pre_close"] + a_pct = asc["close"] / asc["pre_close"] + assert d_pct.iloc[0] >= d_pct.iloc[-1] + assert a_pct.iloc[0] <= a_pct.iloc[-1] + + +async def test_mock_mac_board_and_unusual() -> None: + """板块列表/异动流:非空、字段齐(看板热度榜与异动雷达)。""" + client = MockMacClient() + boards = await client.get_board_list(count=500) + assert len(boards) > 0 + assert {"code", "name", "price", "pre_close"} <= set(boards.columns) + + unusual = await client.get_unusual(market=1, count=60) + assert 0 < len(unusual) <= 12 + assert {"time", "code", "name", "desc", "value"} <= set(unusual.columns) + + +async def test_mock_close_is_noop() -> None: + """lifespan 关闭路径调用 close() 不抛异常。""" + await MockTdxClient().close() + await MockMacClient().close() + + +# ── TestClient 端到端(mock 模式 lifespan)─────────────────────────────────── + + +def test_app_serves_synthetic_data_in_mock_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """EASY_TDX_E2E_MOCK=1 时全应用 lifespan 用合成客户端,行情端点 200。""" + from fastapi.testclient import TestClient + + from easy_tdx.web import create_app + + monkeypatch.setenv(E2E_MOCK_ENV, "1") + app = create_app() + with TestClient(app) as client: + bars = client.get( + "/api/v1/bars", + params={"market": "SH", "code": "600519", "category": "DAY", "count": 30}, + ) + assert bars.status_code == 200 + assert len(bars.json()["data"]) == 30 + + stat = client.get("/api/v1/market/stat") + assert stat.status_code == 200 + assert stat.json()["data"][0]["up_count"] > 0 + + rank = client.get("/api/v1/mac/quote-list", params={"count": 10}) + assert rank.status_code == 200 + assert len(rank.json()["data"]) == 10 diff --git a/web-ui/e2e/README.md b/web-ui/e2e/README.md new file mode 100644 index 0000000..e08a0eb --- /dev/null +++ b/web-ui/e2e/README.md @@ -0,0 +1,57 @@ +# easy-tdx Web UI — Playwright E2E + +无头浏览器端到端测试,覆盖:市场看板(五大指数 SSE)、自选增删、回测全流程 +(取行情→净值图→绩效表→成交记录)、「附加分析」开关(WF 逐窗柱状图 + 一条龙 +评估卡)、策略库保存。 + +## Mock 方案:后端合成数据(EASY_TDX_E2E_MOCK=1),不是 page.route 拦截 + +两种可行路径的取舍: + +| | 后端 mock(已选) | Playwright `page.route` 拦截 | +|---|---|---| +| 回测/WF/评估/自选/策略库 | **走真实后端代码**,schema 变更会被 E2E 捕获 | 静态 JSON,会与后端 schema 漂移 | +| SSE `/stream/quotes`(EventSource) | QuoteStreamer 真轮询合成数据,SSE 全链路被覆盖 | 无法稳定 mock(需流式 body) | +| 任务型端点(提交→轮询→done) | 天然支持 | 要手写状态机 | +| 代价 | 后端多一个 `src/easy_tdx/web/e2e_mock.py`(约 400 行,由单测守护契约) | 无后端改动 | + +实现:`playwright.config.ts` 的 `webServer` 以 `EASY_TDX_E2E_MOCK=1` 启动 +`easy-tdx serve --port 8001`,lifespan 把 TDX/MAC 客户端替换为合成数据客户端 +(确定性随机游走、按 (market, code) 播种,逐轮结果完全一致)。 +`EASY_TDX_CONFIG_DIR` 指向每轮独立的临时目录——自选/策略库/任务从空开始, +断言可以写死,也不污染真实 `~/.easy_tdx`。 + +## 本地跑法 + +```bash +# 仓库根(Python 侧,一次性) +pip install -e ".[web]" # fastapi + uvicorn + +cd web-ui +npm install +npx playwright install chromium # 首次下载浏览器(约 115MB) +npm run build # 必须:serve 从 ../web-ui/dist 托管前端 +npm run test:e2e # = npx playwright test(无头) +``` + +- Python 解释器自动探测仓库 `.venv`;没有 `.venv` 时用 `EASY_TDX_PYTHON` + 环境变量指定(CI 里是系统 `python`)。 +- 调试:`npx playwright test --headed`,或 `--ui` 打开交互式运行器; + 失败时自动留 trace/截图在 `e2e/.results/`(已 gitignore)。 +- 想复用已启动的 serve:直接跑即可(`reuseExistingServer`,本地非 CI 默认开), + 但注意该 serve 必须带 `EASY_TDX_E2E_MOCK=1` 才有合成行情。 + +## CI + +`.github/workflows/ci.yml` 的 `frontend` job 在 typecheck+build 后追加: +安装 Python + `pip install -e ".[web]"` → `npx playwright install chromium` → +`npm run test:e2e`(mock 模式,不需要任何真实行情连接)。 + +## 用例清单 + +| 文件 | 覆盖 | +|---|---| +| `dashboard.spec.ts` | 五大指数区块渲染 + SSE 首帧价格 + 市场统计卡 | +| `watchlist.spec.ts` | 自选加入(含名称补全)/删除/空态 | +| `backtest.spec.ts` | 策略下拉 + 回测全流程 + WF 柱状图 + 一条龙评估卡 | +| `strategies.spec.ts` | 保存策略对话框 + 策略库页可见 | diff --git a/web-ui/e2e/backtest.spec.ts b/web-ui/e2e/backtest.spec.ts new file mode 100644 index 0000000..fbee881 --- /dev/null +++ b/web-ui/e2e/backtest.spec.ts @@ -0,0 +1,54 @@ +// 回测页 E2E:选策略 → 开始回测(自动取行情)→ 净值图 + 绩效表 + 成交记录。 +// +// 行情来自 mock /bars(确定性合成 OHLCV),回测跑真实引擎; +// URL query 指定较短日期区间加快取数(页面 onMounted 回填 startDate/endDate)。 + +import { expect, test } from '@playwright/test' + +test('回测全流程出净值图与绩效表', async ({ page }) => { + await page.goto('/backtest?startDate=2024-01-01&endDate=2025-12-31') + + // 策略下拉加载出内置策略,默认 ma_cross + const strategySelect = page.locator('.strategy-picker select') + await expect(strategySelect).toHaveValue('ma_cross') + const optionCount = await strategySelect.locator('option').count() + expect(optionCount).toBeGreaterThanOrEqual(18) + + // 取行情(mock /bars)+ 回测(真实引擎) + await page.getByRole('button', { name: '开始回测' }).click() + + // 报告区:净值曲线(echarts canvas)+ 绩效指标 + 成交记录 + await expect(page.getByRole('heading', { name: '净值曲线与回撤' })).toBeVisible({ timeout: 60_000 }) + await expect(page.locator('.report-section canvas').first()).toBeVisible() + await expect(page.getByRole('heading', { name: '绩效指标' })).toBeVisible() + await expect(page.getByRole('heading', { name: /成交记录(\d+ 笔)/ })).toBeVisible() + + // 绩效表渲染出具体数值(总收益/夏普等指标行) + const perfSection = page.locator('.report-section', { hasText: '绩效指标' }) + await expect(perfSection.locator('td, .metric-value, .mono').first()).toBeVisible() +}) + +test('勾选附加分析后出现 WF 逐窗柱状图与一条龙评估卡', async ({ page }) => { + await page.goto('/backtest?startDate=2023-01-01&endDate=2025-12-31') + + // 勾选两个「附加分析」开关(v1.27 新增) + await page.getByLabel('Walk-Forward 样本外验证').check() + await expect(page.getByLabel('一条龙评估')).toBeVisible() + await page.getByLabel('一条龙评估').check() + + await page.getByRole('button', { name: '开始回测' }).click() + + // WF:先出现「验证中…」区块,随后逐窗柱状图(echarts canvas)+ 稳定性汇总 + await expect(page.getByRole('heading', { name: 'Walk-Forward 样本外验证' })).toBeVisible({ + timeout: 60_000, + }) + await expect(page.locator('.wf-chart canvas')).toBeVisible({ timeout: 120_000 }) + await expect(page.getByText('盈利窗占比', { exact: true })).toBeVisible() + await expect(page.locator('.wf-summary .stat')).toHaveCount(6) + + // 一条龙评估:综合评分 + 高适配徽标 + 基准对比 + await expect(page.locator('.eval-panel')).toBeVisible({ timeout: 120_000 }) + await expect(page.getByText('综合评分')).toBeVisible() + await expect(page.getByText('对比买入持有')).toBeVisible() + await expect(page.getByText(/适配性体检 \d+\/\d+/)).toBeVisible() +}) diff --git a/web-ui/e2e/dashboard.spec.ts b/web-ui/e2e/dashboard.spec.ts new file mode 100644 index 0000000..e292957 --- /dev/null +++ b/web-ui/e2e/dashboard.spec.ts @@ -0,0 +1,23 @@ +// 看板 E2E:五大指数区块加载 + SSE 合成行情到达后价格渲染。 +// +// 数据链路:QuoteStreamer 轮询 mock 客户端 → SSE /stream/quotes → +// quoteStore → .idx-card。首帧在打开页面后 1~3 秒内到达(轮询循环 +// 「无订阅 1s 待命 → 有订阅立即拉一轮」),盘外时段也不受影响。 + +import { expect, test } from '@playwright/test' + +test('市场看板加载五大指数区块并渲染实时价格', async ({ page }) => { + await page.goto('/') + + const cards = page.locator('.idx-card') + await expect(cards).toHaveCount(5) + + const names = cards.locator('.idx-name') + await expect(names).toHaveText(['上证指数', '深证成指', '创业板指', '科创50', '沪深300']) + + // SSE 首帧到达前价格是占位符「—」;合成行情到达后变为数值 + await expect(cards.first().locator('.idx-price')).toHaveText(/\d/, { timeout: 30_000 }) + + // 市场统计卡也从 mock /market/stat 拿到数据(不再是「加载中…」) + await expect(page.locator('.card', { hasText: '市场统计' }).locator('.stat-nums')).toBeVisible() +}) diff --git a/web-ui/e2e/strategies.spec.ts b/web-ui/e2e/strategies.spec.ts new file mode 100644 index 0000000..c913c00 --- /dev/null +++ b/web-ui/e2e/strategies.spec.ts @@ -0,0 +1,25 @@ +// 策略库 E2E:回测出结果 → 保存策略(含成绩快照)→ 策略库页可见。 + +import { expect, test } from '@playwright/test' + +test('回测结果保存到策略库并在策略库页可见', async ({ page }) => { + await page.goto('/backtest?startDate=2024-01-01&endDate=2025-12-31') + + // 跑一次回测拿到结果(保存按钮只在有结果时出现) + await page.getByRole('button', { name: '开始回测' }).click() + await expect(page.getByRole('button', { name: '💾 保存策略' })).toBeVisible({ timeout: 60_000 }) + + // 打开保存对话框(名称已预填「双均线交叉 · 000001」) + await page.getByRole('button', { name: '💾 保存策略' }).click() + await expect(page.locator('.modal')).toBeVisible() + const nameInput = page.getByPlaceholder('给这个策略起个名') + await expect(nameInput).toHaveValue('双均线交叉 · 000001') + await nameInput.fill('E2E 冒烟策略') + + await page.locator('.modal-actions .primary').click() + await expect(page.getByText('✓ 已保存到策略库')).toBeVisible() + + // 策略库页列出刚保存的条目(SQLite strategies.db 落在临时 EASY_TDX_CONFIG_DIR) + await page.goto('/strategies') + await expect(page.getByText('E2E 冒烟策略').first()).toBeVisible({ timeout: 30_000 }) +}) diff --git a/web-ui/e2e/watchlist.spec.ts b/web-ui/e2e/watchlist.spec.ts new file mode 100644 index 0000000..93def79 --- /dev/null +++ b/web-ui/e2e/watchlist.spec.ts @@ -0,0 +1,24 @@ +// 自选页 E2E:加入自选(行情校验 + 名称补全走 mock)→ 表格出现 → 删除 → 消失。 +// +// 每轮 E2E 用独立的临时 EASY_TDX_CONFIG_DIR,自选从空开始,断言可写死。 + +import { expect, test } from '@playwright/test' + +test('自选页增删自选', async ({ page }) => { + await page.goto('/watchlist') + + // 初始为空(临时配置目录) + await expect(page.locator('.empty-row')).toBeVisible() + + // 加入 600519(市场自动识别 SH;名称走 mock /mac/symbol-info → 贵州茅台) + await page.fill('.code-input', '600519') + await page.getByRole('button', { name: '加入自选' }).click() + await expect(page.locator('.data-row')).toHaveCount(1, { timeout: 30_000 }) + await expect(page.locator('.data-row .cell-name')).toHaveText('贵州茅台') + await expect(page.locator('.data-row .cell-code')).toHaveText('SH600519') + + // 删除后表格回到空态 + await page.locator('.data-row .del').first().click() + await expect(page.locator('.data-row')).toHaveCount(0) + await expect(page.locator('.empty-row')).toBeVisible() +}) diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json index e7101a9..91ee3b3 100644 --- a/web-ui/package-lock.json +++ b/web-ui/package-lock.json @@ -14,6 +14,7 @@ "vue-router": "^4.6.4" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@types/node": "^24.13.2", "@vitejs/plugin-vue": "^6.0.7", "@vue/tsconfig": "^0.9.1", @@ -137,6 +138,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", @@ -1122,6 +1139,53 @@ } } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", diff --git a/web-ui/package.json b/web-ui/package.json index 1a99966..195f786 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vue-tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test" }, "dependencies": { "echarts": "^6.1.0", @@ -15,6 +16,7 @@ "vue-router": "^4.6.4" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@types/node": "^24.13.2", "@vitejs/plugin-vue": "^6.0.7", "@vue/tsconfig": "^0.9.1", diff --git a/web-ui/playwright.config.ts b/web-ui/playwright.config.ts new file mode 100644 index 0000000..2dbdba0 --- /dev/null +++ b/web-ui/playwright.config.ts @@ -0,0 +1,70 @@ +// Playwright E2E 配置:mock 模式的 easy-tdx serve + 已构建的前端 dist。 +// +// 运行前置: +// 1. `npm run build`(serve 从仓库根 web-ui/dist 托管前端,SPA fallback); +// 2. 后端可用(仓库根 `pip install -e ".[web]"`)——自动探测仓库 .venv, +// 否则可用 EASY_TDX_PYTHON 指定解释器(CI 里是 `python`)。 +// +// 行情全部来自合成数据(EASY_TDX_E2E_MOCK=1,见 src/easy_tdx/web/e2e_mock.py), +// 不连真实通达信服务器、不受交易时段限制;回测/WF/评估/自选/策略库走真实后端。 +// 详见 e2e/README.md。 + +import { existsSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { defineConfig } from '@playwright/test' + +const PORT = Number(process.env.E2E_PORT ?? 8001) +const BASE_URL = `http://127.0.0.1:${PORT}` +const WEB_UI_DIR = path.dirname(fileURLToPath(import.meta.url)) + +/** 启动 serve 的 Python 解释器:优先仓库 .venv(开发态 editable 安装), + * 没有则回退 EASY_TDX_PYTHON / 系统 python(CI 态 pip install -e 之后)。 */ +function resolvePython(): string { + const repoRoot = path.resolve(WEB_UI_DIR, '..') + if (existsSync(path.join(repoRoot, '.venv/Scripts/python.exe'))) { + return path.join(repoRoot, '.venv/Scripts/python.exe') + } + if (existsSync(path.join(repoRoot, '.venv/bin/python'))) { + return path.join(repoRoot, '.venv/bin/python') + } + return process.env.EASY_TDX_PYTHON ?? 'python' +} + +// 每次运行独立的临时配置目录:watchlist.db / strategies.db / tasks.db 写在这里, +// 不污染真实 ~/.easy_tdx,且每轮 E2E 从空自选、空策略库开始(断言可写死)。 +const RUNTIME_CONFIG_DIR = mkdtempSync(path.join(tmpdir(), 'easy-tdx-e2e-')) + +export default defineConfig({ + testDir: './e2e', + timeout: 180_000, + // 单 worker 串行:全部用例共享同一个 serve(SQLite 自选/策略库会互相干扰) + workers: 1, + fullyParallel: false, + retries: process.env.CI ? 1 : 0, + reporter: [['list']], + use: { + baseURL: BASE_URL, + headless: true, + locale: 'zh-CN', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + actionTimeout: 15_000, + }, + expect: { + timeout: 20_000, + }, + outputDir: './e2e/.results', + webServer: { + command: `${resolvePython()} -m easy_tdx serve --host 127.0.0.1 --port ${PORT} --no-open-browser`, + url: `${BASE_URL}/api/v1/backtest/strategies`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + EASY_TDX_E2E_MOCK: '1', + EASY_TDX_CONFIG_DIR: RUNTIME_CONFIG_DIR, + }, + }, +})