Files
tick-stock-panel/backend/tests/test_watchlist_groups.py
T
shy3130 83b96e2088 自选分组支持多组并存 (M:N)
- 后端: watchlist 条目 group_id 单值列迁移为 group_ids 列表列, 首次
  写入前自动备份 .bak; 新增 add_to_group/remove_from_group 及
  POST/DELETE /groups/{gid}/members/{symbol} 端点
- 前端: 分组选择器改为勾选式弹出面板, 触发器显示多色点(最多3+N);
  移除语义收窄为仅移出当前分组, 移出全部后提示标的仍在自选
- 适配: 分组页签计数/筛选、等权涨跌幅、分组卡片、监控自选导入、
  回测/挖掘两页分组导入均按 group_ids 归属计入每个所属分组
2026-08-20 16:15:13 +08:00

281 lines
11 KiB
Python

"""自选分组持久化与 API 契约。"""
from types import SimpleNamespace
from unittest.mock import MagicMock
import polars as pl
import pytest
from fastapi import HTTPException
from app.api import watchlist as watchlist_api
from app.config import settings
from app.services import watchlist
def _request():
repo = MagicMock()
repo.get_name_map.return_value = {}
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(repo=repo)))
def test_historical_watchlist_is_read_as_ungrouped(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
path = tmp_path / "user_data" / "watchlist.parquet"
path.parent.mkdir(parents=True)
pl.DataFrame({
"symbol": ["600000.SH"],
"added_at": ["2026-08-08T10:00:00"],
"note": [""],
}).write_parquet(path)
assert watchlist.list_symbols()[0]["group_ids"] == []
def test_group_lifecycle_preserves_watchlist_entries(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
groups, created = watchlist.create_group(" 短线 ", "orange")
assert groups == [{"id": created["id"], "name": "短线", "color": "orange"}]
watchlist.add("600000.SH", group_id=created["id"])
watchlist.add("000001.SZ")
assert watchlist.list_symbols()[1]["group_ids"] == [created["id"]]
renamed = watchlist.rename_group(created["id"], "观察", "fuchsia")
assert renamed[0]["name"] == "观察"
assert renamed[0]["color"] == "fuchsia"
remaining, rows = watchlist.delete_group(created["id"])
assert remaining == []
assert {row["symbol"] for row in rows} == {"600000.SH", "000001.SZ"}
assert all(row["group_ids"] == [] for row in rows)
def test_group_validation_and_assignment_errors(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
_, created = watchlist.create_group("核心")
watchlist.add("600000.SH")
with pytest.raises(ValueError, match="已存在"):
watchlist.create_group("核心")
with pytest.raises(ValueError, match="颜色"):
watchlist.create_group("无效颜色", "black")
with pytest.raises(ValueError, match="颜色"):
watchlist.rename_group(created["id"], "核心", "black")
with pytest.raises(ValueError, match="不存在"):
watchlist.set_group("600000.SH", "missing")
with pytest.raises(KeyError):
watchlist.set_group("000001.SZ", created["id"])
rows = watchlist.set_group("600000.SH", created["id"])
assert rows[0]["group_ids"] == [created["id"]]
rows = watchlist.set_group("600000.SH", None)
assert rows[0]["group_ids"] == []
def test_reorder_groups(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
_, first = watchlist.create_group("一")
_, second = watchlist.create_group("二")
_, third = watchlist.create_group("三")
reordered = watchlist.reorder_groups([third["id"], first["id"], second["id"]])
assert [group["name"] for group in reordered] == ["三", "一", "二"]
assert [group["name"] for group in watchlist.list_groups()] == ["三", "一", "二"]
# ids 与现有分组不一致 (缺失 / 多余 / 重复) 均拒绝
with pytest.raises(ValueError, match="不一致"):
watchlist.reorder_groups([first["id"], second["id"]])
with pytest.raises(ValueError, match="不一致"):
watchlist.reorder_groups([first["id"], second["id"], third["id"], "missing"])
with pytest.raises(ValueError, match="不一致"):
watchlist.reorder_groups([first["id"], first["id"], second["id"], third["id"]])
# 失败请求不改变现有顺序
assert [group["name"] for group in watchlist.list_groups()] == ["三", "一", "二"]
def test_reorder_groups_api(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
_, first = watchlist.create_group("一")
_, second = watchlist.create_group("二")
result = watchlist_api.reorder_groups(
watchlist_api.GroupReorderRequest(ordered_ids=[second["id"], first["id"]])
)
assert [group["name"] for group in result["groups"]] == ["二", "一"]
with pytest.raises(HTTPException) as exc_info:
watchlist_api.reorder_groups(
watchlist_api.GroupReorderRequest(ordered_ids=["missing"])
)
assert exc_info.value.status_code == 400
def test_group_api_contract(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
request = _request()
created = watchlist_api.create_group(
watchlist_api.GroupNameRequest(name="中线", color="teal")
)
group_id = created["group"]["id"]
assert created["group"]["color"] == "teal"
added = watchlist_api.add_one(
watchlist_api.AddRequest(symbol="600000.SH", group_id=group_id),
request,
)
assert added["symbols"][0]["group_ids"] == [group_id]
moved = watchlist_api.assign_group(
"600000.SH",
watchlist_api.GroupAssignRequest(group_id=None),
request,
)
assert moved["symbols"][0]["group_ids"] == []
with pytest.raises(HTTPException) as exc_info:
watchlist_api.rename_group("missing", watchlist_api.GroupNameRequest(name="无效"))
assert exc_info.value.status_code == 404
def test_historical_groups_default_to_sky(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
path = tmp_path / "user_data" / "watchlist_groups.json"
path.parent.mkdir(parents=True)
path.write_text(
'[{"id":"legacy","name":"旧分组"},'
'{"id":"invalid","name":"未知颜色","color":"black"}]',
encoding="utf-8",
)
assert watchlist.list_groups() == [
{"id": "legacy", "name": "旧分组", "color": "sky"},
{"id": "invalid", "name": "未知颜色", "color": "sky"},
]
def test_clear_group_moves_members_to_ungrouped(monkeypatch, tmp_path):
"""清空分组:成员变未分组,分组定义保留。"""
monkeypatch.setattr(settings, "data_dir", tmp_path)
_, group = watchlist.create_group("芯片")
watchlist.add("600000.SH", group_id=group["id"])
watchlist.add("000001.SZ", group_id=group["id"])
watchlist.add("300750.SZ") # 不在任何分组
rows = watchlist.clear_group(group["id"])
# 3 只都还在,group_id 全部为 None
assert len(rows) == 3
assert all(r["group_ids"] == [] for r in rows)
# 分组定义仍在
assert any(g["id"] == group["id"] for g in watchlist.list_groups())
# 清空不存在的分组 → KeyError
with pytest.raises(KeyError):
watchlist.clear_group("missing")
def test_clear_group_api(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "data_dir", tmp_path)
request = _request()
created = watchlist_api.create_group(
watchlist_api.GroupNameRequest(name="中线", color="teal")
)
group_id = created["group"]["id"]
watchlist.add("600000.SH", group_id=group_id)
watchlist.add("000001.SZ", group_id=group_id)
result = watchlist_api.clear_group(group_id, request)
assert all(s["group_ids"] == [] for s in result["symbols"])
# 不存在的分组 → 404
with pytest.raises(HTTPException) as exc:
watchlist_api.clear_group("missing", request)
assert exc.value.status_code == 404
# ── 多组成员关系 (M:N) ──────────────────────────────────────
def test_multi_group_membership(monkeypatch, tmp_path):
"""一股可同时属于多个分组; 移出一个不影响其他。"""
monkeypatch.setattr(settings, "data_dir", tmp_path)
_, ga = watchlist.create_group("半导体")
_, gb = watchlist.create_group("反包备选")
watchlist.add("600118.SH", group_id=ga["id"])
rows = watchlist.add_to_group("600118.SH", gb["id"])
got = next(r for r in rows if r["symbol"] == "600118.SH")
assert got["group_ids"] == [ga["id"], gb["id"]] # 两组并存
# 重复加入幂等
rows = watchlist.add_to_group("600118.SH", gb["id"])
got = next(r for r in rows if r["symbol"] == "600118.SH")
assert got["group_ids"] == [ga["id"], gb["id"]]
# 移出一个, 另一个保留
rows = watchlist.remove_from_group("600118.SH", ga["id"])
got = next(r for r in rows if r["symbol"] == "600118.SH")
assert got["group_ids"] == [gb["id"]]
# 移出最后一个 → 未分组(仍在自选)
rows = watchlist.remove_from_group("600118.SH", gb["id"])
got = next(r for r in rows if r["symbol"] == "600118.SH")
assert got["group_ids"] == []
assert any(r["symbol"] == "600118.SH" for r in rows)
# 不存在的分组/标的
with pytest.raises(ValueError, match="不存在"):
watchlist.add_to_group("600118.SH", "missing")
with pytest.raises(KeyError):
watchlist.remove_from_group("000001.SZ", ga["id"])
def test_set_group_exclusive_keeps_only_one(monkeypatch, tmp_path):
"""互斥设定: 已在多组的标的被 set 后只保留指定组。"""
monkeypatch.setattr(settings, "data_dir", tmp_path)
_, ga = watchlist.create_group("一")
_, gb = watchlist.create_group("二")
watchlist.add("600000.SH", group_id=ga["id"])
watchlist.add_to_group("600000.SH", gb["id"])
rows = watchlist.set_group("600000.SH", ga["id"])
assert rows[0]["group_ids"] == [ga["id"]]
def test_clear_group_strips_only_that_group(monkeypatch, tmp_path):
"""清空分组只摘该组标签, 其他组成员关系保留。"""
monkeypatch.setattr(settings, "data_dir", tmp_path)
_, ga = watchlist.create_group("一")
_, gb = watchlist.create_group("二")
watchlist.add("600000.SH", group_id=ga["id"])
watchlist.add_to_group("600000.SH", gb["id"])
watchlist.add("000001.SZ", group_id=ga["id"])
rows = watchlist.clear_group(ga["id"])
a = next(r for r in rows if r["symbol"] == "600000.SH")
b = next(r for r in rows if r["symbol"] == "000001.SZ")
assert a["group_ids"] == [gb["id"]] # 二组保留
assert b["group_ids"] == []
def test_legacy_single_group_id_migration(monkeypatch, tmp_path):
"""旧 schema(单值 group_id 列)读取迁移 + 首次写回前自动备份 .bak。"""
monkeypatch.setattr(settings, "data_dir", tmp_path)
path = tmp_path / "user_data" / "watchlist.parquet"
path.parent.mkdir(parents=True)
pl.DataFrame({
"symbol": ["600000.SH", "000001.SZ"],
"added_at": ["2026-08-08T10:00:00"] * 2,
"note": ["", ""],
"group_id": ["g1", None],
}).write_parquet(path)
rows = watchlist.list_symbols()
assert rows[0]["group_ids"] == ["g1"]
assert rows[1]["group_ids"] == []
assert not (tmp_path / "user_data" / "watchlist.parquet.bak").exists() # 只读不备份
# 触发写入 → 备份生成, 文件落新 schema
watchlist.add("300750.SZ")
assert (tmp_path / "user_data" / "watchlist.parquet.bak").exists()
df = pl.read_parquet(path)
assert "group_ids" in df.columns and "group_id" not in df.columns