chore: establish stable application baseline
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from database import ReviewDatabase
|
||||
from security import hash_password, verify_password
|
||||
|
||||
|
||||
class AccountAccessTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.database = ReviewDatabase(Path(self.temp.name) / "review.db")
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_first_user_is_admin_and_following_users_are_regular(self):
|
||||
first = self.database.create_user("admin_user", "salt", "hash")
|
||||
second = self.database.create_user("member_user", "salt", "hash")
|
||||
|
||||
self.assertEqual(first["role"], "admin")
|
||||
self.assertEqual(second["role"], "user")
|
||||
self.assertEqual(self.database.user_access(first["id"])["role"], "admin")
|
||||
self.assertEqual(self.database.user_access(second["id"])["role"], "user")
|
||||
|
||||
def test_admin_role_can_also_hold_an_explicit_membership(self):
|
||||
admin = self.database.create_user("admin_member", "salt", "hash")
|
||||
|
||||
updated = self.database.update_membership(
|
||||
admin["id"],
|
||||
"active",
|
||||
"内部会员",
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
"2026-08-23T00:00:00+00:00",
|
||||
)
|
||||
access = self.database.user_access(admin["id"])
|
||||
|
||||
self.assertTrue(updated)
|
||||
self.assertEqual(access["role"], "admin")
|
||||
self.assertEqual(access["membership_status"], "active")
|
||||
|
||||
def test_membership_mode_system_settings_and_usage_are_persistent(self):
|
||||
user = self.database.create_user("member_user", "salt", "hash")
|
||||
self.database.update_user_llm_mode(user["id"], "platform")
|
||||
updated = self.database.update_membership(
|
||||
user["id"],
|
||||
"active",
|
||||
"内部会员",
|
||||
"2026-07-22T00:00:00+00:00",
|
||||
"2026-08-23T00:00:00+00:00",
|
||||
)
|
||||
self.database.save_system_setting("credentials", "encrypted")
|
||||
self.database.record_llm_usage(
|
||||
user["id"], "mentor", "platform", "model", "success", 1200
|
||||
)
|
||||
|
||||
access = self.database.user_access(user["id"])
|
||||
self.assertTrue(updated)
|
||||
self.assertEqual(access["llm_mode"], "platform")
|
||||
self.assertEqual(access["membership_status"], "active")
|
||||
self.assertEqual(access["membership_plan"], "内部会员")
|
||||
self.assertEqual(self.database.get_system_setting("credentials"), "encrypted")
|
||||
self.assertEqual(
|
||||
self.database.count_llm_usage_since(
|
||||
user["id"], "platform", "2026-01-01T00:00:00+00:00"
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_password_can_be_rotated_without_changing_account_access(self):
|
||||
old_salt, old_hash = hash_password("OldPassword123")
|
||||
user = self.database.create_user("password_user", old_salt, old_hash)
|
||||
new_salt, new_hash = hash_password("NewPassword456")
|
||||
|
||||
self.assertTrue(
|
||||
self.database.update_user_password(user["id"], new_salt, new_hash)
|
||||
)
|
||||
stored = self.database.user_password(user["id"])
|
||||
self.assertFalse(
|
||||
verify_password("OldPassword123", stored["password_salt"], stored["password_hash"])
|
||||
)
|
||||
self.assertTrue(
|
||||
verify_password("NewPassword456", stored["password_salt"], stored["password_hash"])
|
||||
)
|
||||
self.assertEqual(self.database.user_access(user["id"])["role"], "admin")
|
||||
|
||||
def test_latest_real_snapshot_skips_demo_and_supports_strict_previous_date(self):
|
||||
self.database.save_snapshot(
|
||||
"20260720", "tushare", {"meta": {"trade_date": "2026-07-20", "source": "tushare"}}
|
||||
)
|
||||
self.database.save_snapshot(
|
||||
"20260721", "demo", {"meta": {"trade_date": "2026-07-21", "source": "demo"}}
|
||||
)
|
||||
|
||||
latest = self.database.get_latest_real_snapshot("20260722")
|
||||
previous = self.database.get_latest_real_snapshot("20260721", strictly_before=True)
|
||||
self.assertEqual(latest["meta"]["trade_date"], "2026-07-20")
|
||||
self.assertEqual(previous["meta"]["trade_date"], "2026-07-20")
|
||||
|
||||
def test_stock_master_search_supports_exact_name_and_code(self):
|
||||
self.database.upsert_stock_master(
|
||||
[
|
||||
{
|
||||
"ts_code": "002141.SZ",
|
||||
"name": "贤丰控股",
|
||||
"industry": "元件",
|
||||
"market": "主板",
|
||||
"list_date": "20071228",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(self.database.search_stock_master("贤丰控股")[0]["code"], "002141")
|
||||
self.assertEqual(self.database.search_stock_master("002141")[0]["name"], "贤丰控股")
|
||||
|
||||
def test_review_notes_are_scoped_to_their_owner(self):
|
||||
first = self.database.create_user("note_owner", "salt", "hash")
|
||||
second = self.database.create_user("other_reader", "salt", "hash")
|
||||
note_id = self.database.save_note(
|
||||
first["id"], "002141", "贤丰控股", "20260721", "只属于甲", "明日观察"
|
||||
)
|
||||
|
||||
self.assertEqual(len(self.database.list_notes(first["id"], code="002141")), 1)
|
||||
self.assertEqual(self.database.list_notes(second["id"], code="002141"), [])
|
||||
with self.assertRaises(ValueError):
|
||||
self.database.save_note(
|
||||
second["id"],
|
||||
"002141",
|
||||
"贤丰控股",
|
||||
"20260721",
|
||||
"越权修改",
|
||||
"",
|
||||
note_id,
|
||||
)
|
||||
self.assertFalse(self.database.delete_note(second["id"], note_id))
|
||||
self.assertTrue(self.database.delete_note(first["id"], note_id))
|
||||
|
||||
def test_watchlist_is_scoped_to_its_owner(self):
|
||||
first = self.database.create_user("watch_owner", "salt", "hash")
|
||||
second = self.database.create_user("other_watcher", "salt", "hash")
|
||||
self.database.save_watchlist(first["id"], "002141", "贤丰控股", "元件", "red")
|
||||
self.database.save_watchlist(second["id"], "002141", "贤丰控股", "元件", "blue")
|
||||
|
||||
self.assertEqual(self.database.list_watchlist(first["id"])[0]["color"], "red")
|
||||
self.assertEqual(self.database.list_watchlist(second["id"])[0]["color"], "blue")
|
||||
self.assertFalse(self.database.delete_watchlist(second["id"], "000001"))
|
||||
self.assertTrue(self.database.delete_watchlist(first["id"], "002141"))
|
||||
self.assertEqual(self.database.list_watchlist(first["id"]), [])
|
||||
self.assertEqual(len(self.database.list_watchlist(second["id"])), 1)
|
||||
|
||||
def test_legacy_review_notes_are_assigned_to_first_account(self):
|
||||
legacy_path = Path(self.temp.name) / "legacy.db"
|
||||
connection = sqlite3.connect(legacy_path)
|
||||
try:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_salt TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO users
|
||||
(username, password_salt, password_hash, created_at, updated_at)
|
||||
VALUES ('legacy_admin', 'salt', 'hash', '2026-01-01', '2026-01-01');
|
||||
CREATE TABLE review_notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL DEFAULT '',
|
||||
stock_name TEXT NOT NULL DEFAULT '',
|
||||
trade_date TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
plan TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO review_notes
|
||||
(code, stock_name, trade_date, content, plan, created_at, updated_at)
|
||||
VALUES ('', '', '20260721', '旧复盘', '', '2026-07-21', '2026-07-21');
|
||||
CREATE TABLE watchlist (
|
||||
code TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
sector TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT 'red',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO watchlist
|
||||
(code, name, sector, color, created_at, updated_at)
|
||||
VALUES ('002141', '贤丰控股', '元件', 'red', '2026-07-21', '2026-07-21');
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
migrated = ReviewDatabase(legacy_path)
|
||||
notes = migrated.list_notes(1)
|
||||
self.assertEqual(len(notes), 1)
|
||||
self.assertEqual(notes[0]["content"], "旧复盘")
|
||||
self.assertEqual(migrated.list_watchlist(1)[0]["code"], "002141")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from heaven_engine import build_five_phase_field
|
||||
|
||||
|
||||
class FivePhaseFrameworkTests(unittest.TestCase):
|
||||
def test_public_field_uses_year_current_qi_day_contract(self):
|
||||
field = build_five_phase_field("2026-06-15")
|
||||
|
||||
self.assertEqual(
|
||||
field["framework"]["weights"],
|
||||
{
|
||||
"year_movement": 30,
|
||||
"sitian_zaiquan": 20,
|
||||
"sitian": 15,
|
||||
"zaiquan": 5,
|
||||
"host_qi": 20,
|
||||
"guest_qi": 25,
|
||||
"day": 5,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
[(layer["id"], layer["weight"]) for layer in field["framework"]["layers"]],
|
||||
[("year", 50), ("current", 45), ("day", 5)],
|
||||
)
|
||||
self.assertEqual(sum(item["score"] for item in field["balance"]), 100)
|
||||
|
||||
def test_sitian_and_zaiquan_follow_half_year_dominance(self):
|
||||
first_half = build_five_phase_field("2026-06-15")
|
||||
second_half = build_five_phase_field("2026-08-20")
|
||||
|
||||
self.assertEqual(first_half["framework"]["weights"]["sitian"], 15)
|
||||
self.assertEqual(first_half["framework"]["weights"]["zaiquan"], 5)
|
||||
self.assertEqual(first_half["six_qi"]["ruling"], "司天")
|
||||
self.assertEqual(second_half["framework"]["weights"]["sitian"], 5)
|
||||
self.assertEqual(second_half["framework"]["weights"]["zaiquan"], 15)
|
||||
self.assertEqual(second_half["six_qi"]["ruling"], "在泉")
|
||||
|
||||
def test_guest_host_relation_and_anchor_alignment_are_explicit(self):
|
||||
third_qi = build_five_phase_field("2026-06-15")
|
||||
final_qi = build_five_phase_field("2026-12-10")
|
||||
controlled = build_five_phase_field("2025-02-10")
|
||||
|
||||
self.assertEqual(third_qi["framework"]["relations"]["guest_host"]["label"], "客主同气")
|
||||
self.assertEqual(third_qi["six_qi"]["alignment"], "司天同位")
|
||||
self.assertEqual(final_qi["framework"]["relations"]["guest_host"]["label"], "客生主")
|
||||
self.assertEqual(final_qi["six_qi"]["alignment"], "在泉同位")
|
||||
self.assertEqual(controlled["framework"]["relations"]["guest_host"]["order"], "客胜为从")
|
||||
|
||||
def test_tianfu_and_suihui_use_traditional_year_positions(self):
|
||||
taiyi = build_five_phase_field("2038-06-15")
|
||||
non_suihui = build_five_phase_field("2022-06-15")
|
||||
|
||||
self.assertEqual(
|
||||
taiyi["framework"]["relations"]["annual_pattern"]["primary"],
|
||||
"太乙天符",
|
||||
)
|
||||
self.assertFalse(
|
||||
non_suihui["framework"]["relations"]["annual_pattern"]["is_suihui"]
|
||||
)
|
||||
|
||||
def test_public_field_has_no_observation_hour(self):
|
||||
field = build_five_phase_field("2026-07-18")
|
||||
|
||||
self.assertNotIn("time", field["pillars"])
|
||||
self.assertNotIn("observation_time", field)
|
||||
|
||||
def test_sector_catalog_lists_all_rules_and_applies_manual_overrides(self):
|
||||
field = build_five_phase_field(
|
||||
"2026-07-18",
|
||||
{"电力": "水", "低空经济": "木"},
|
||||
)
|
||||
groups = {item["element"]: item["industries"] for item in field["sector_catalog"]}
|
||||
names = {
|
||||
element: {item["name"]: item["classification_source"] for item in items}
|
||||
for element, items in groups.items()
|
||||
}
|
||||
|
||||
self.assertEqual(set(groups), {"木", "火", "土", "金", "水"})
|
||||
self.assertNotIn("电力", names["火"])
|
||||
self.assertEqual(names["水"]["电力"], "manual")
|
||||
self.assertEqual(names["木"]["低空经济"], "manual")
|
||||
self.assertEqual(sum(len(items) for items in groups.values()), 144)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from server import DashboardService
|
||||
|
||||
|
||||
class SearchDatabaseStub:
|
||||
def __init__(self) -> None:
|
||||
self.directory = {
|
||||
"schema_version": 2,
|
||||
"items": [
|
||||
{
|
||||
"id": "881107.TI",
|
||||
"code": "881107.TI",
|
||||
"name": "油气开采及服务",
|
||||
"type": "sector",
|
||||
"subtitle": "行业板块",
|
||||
"member_count": 19,
|
||||
},
|
||||
{
|
||||
"id": "885728.TI",
|
||||
"code": "885728.TI",
|
||||
"name": "人工智能",
|
||||
"type": "theme",
|
||||
"subtitle": "概念题材",
|
||||
"member_count": 1079,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def get_data_snapshot(self, kind: str, cache_key: str):
|
||||
if (kind, cache_key) == ("search_directory", "ths"):
|
||||
return self.directory
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def search_stock_master(query: str, limit: int = 12):
|
||||
if query in {"002141", "贤丰控股"}:
|
||||
return [
|
||||
{
|
||||
"ts_code": "002141.SZ",
|
||||
"code": "002141",
|
||||
"name": "贤丰控股",
|
||||
"industry": "元件",
|
||||
"market": "主板",
|
||||
"list_date": "20071228",
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
class GlobalSearchTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.service = DashboardService.__new__(DashboardService)
|
||||
self.service.database = SearchDatabaseStub()
|
||||
self.service._system_credentials = {"tushare_token": ""}
|
||||
|
||||
def test_search_groups_stock_sector_theme_and_index(self):
|
||||
stock = self.service.search_entities("002141", "2026-07-22")
|
||||
sector = self.service.search_entities("油气", "2026-07-22")
|
||||
theme = self.service.search_entities("人工智能", "2026-07-22")
|
||||
index = self.service.search_entities("上证指数", "2026-07-22")
|
||||
|
||||
self.assertEqual(stock["groups"]["stocks"][0]["name"], "贤丰控股")
|
||||
self.assertEqual(stock["groups"]["stocks"][0]["industry"], "元件")
|
||||
self.assertEqual(sector["groups"]["sectors"][0]["code"], "881107.TI")
|
||||
self.assertEqual(theme["groups"]["themes"][0]["code"], "885728.TI")
|
||||
self.assertEqual(index["groups"]["indices"][0]["code"], "000001.SH")
|
||||
|
||||
def test_empty_query_returns_all_groups_without_remote_lookup(self):
|
||||
result = self.service.search_entities("", "2026-07-22")
|
||||
self.assertEqual(
|
||||
result["groups"],
|
||||
{"stocks": [], "sectors": [], "themes": [], "indices": []},
|
||||
)
|
||||
|
||||
def test_frontend_reuses_full_stock_detail_and_renders_market_daily_k(self):
|
||||
static_dir = Path(__file__).resolve().parents[1] / "static"
|
||||
html = (static_dir / "index.html").read_text(encoding="utf-8")
|
||||
script = (static_dir / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('id="globalSearchButton"', html)
|
||||
self.assertIn('id="globalSearchDialog"', html)
|
||||
self.assertIn('id="entityDetailDialog"', html)
|
||||
self.assertIn("日 K 与成交量", html)
|
||||
self.assertIn('event.key.toLowerCase() !== "k"', script)
|
||||
self.assertIn('openStock(item.id, { code: item.code', script)
|
||||
self.assertNotIn('include_notes', script)
|
||||
self.assertIn('const candles = (series || [])', script)
|
||||
self.assertIn('renderStockNotes(payload.notes || [])', script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from heaven_engine import _market_line_scores, build_manual_market_hexagram
|
||||
from realtime_aggregator import WebRealtimeAggregator
|
||||
from server import DashboardService
|
||||
from tushare_client import TushareClient
|
||||
|
||||
|
||||
class HeavenMarketLineTests(unittest.TestCase):
|
||||
def test_index_external_uses_only_current_index_change(self):
|
||||
dashboard = {
|
||||
"overview": {
|
||||
"up_count": 1740,
|
||||
"down_count": 3710,
|
||||
"amount_billion": 27180.8,
|
||||
"sentiment_score": 27,
|
||||
"seal_rate": 57.3,
|
||||
"limit_up_count": 55,
|
||||
"limit_down_count": 267,
|
||||
},
|
||||
"sectors": [],
|
||||
"sector_rotation": [],
|
||||
}
|
||||
index_context = {
|
||||
"aggregate": {
|
||||
"average_pct_chg": 0.187,
|
||||
"average_return_5d": -5.606,
|
||||
}
|
||||
}
|
||||
|
||||
scores = _market_line_scores(
|
||||
dashboard,
|
||||
[{"amount_billion": 25000}],
|
||||
index_context,
|
||||
{},
|
||||
{},
|
||||
[],
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(scores[5]["score"], 0.187 / 3)
|
||||
self.assertGreater(scores[5]["score"], 0)
|
||||
self.assertIn("不参与外显阴阳", scores[5]["evidence"][1])
|
||||
|
||||
def test_manual_calibration_preserves_six_lines_and_marks_user_evidence(self):
|
||||
chart = build_manual_market_hexagram(
|
||||
[8, 7, 6, 9, 8, 7],
|
||||
"20260722",
|
||||
{"name": "元件", "taxonomy": "sw_l2"},
|
||||
{"code": "002141", "name": "贤丰控股"},
|
||||
{},
|
||||
"人工核对",
|
||||
)
|
||||
|
||||
self.assertTrue(chart["manual_calibration"])
|
||||
self.assertEqual([line["value"] for line in chart["hexagram"]["lines"]], [8, 7, 6, 9, 8, 7])
|
||||
self.assertEqual(chart["hexagram"]["moving_lines"], [3, 4])
|
||||
self.assertIn("用户手动校准", chart["hexagram"]["lines"][0]["evidence"][0])
|
||||
|
||||
def test_quantitative_supplement_repairs_only_failed_lines_and_recalculates(self):
|
||||
trade_date = "20260722"
|
||||
dashboard = {
|
||||
"overview": {
|
||||
"sentiment_score": 32,
|
||||
"seal_rate": 48,
|
||||
"amount_billion": 16500,
|
||||
"up_count": 1800,
|
||||
"down_count": 3500,
|
||||
"limit_up_count": 35,
|
||||
"limit_down_count": 192,
|
||||
},
|
||||
"limits": [{"amount_billion": 12}, {"amount_billion": 25}],
|
||||
"sectors": [],
|
||||
"sector_rotation": [],
|
||||
}
|
||||
history = [{"amount_billion": 16000}, {"amount_billion": 15800}]
|
||||
index_context = {
|
||||
"trade_date": trade_date,
|
||||
"source": "tushare",
|
||||
"realtime": False,
|
||||
"precise": False,
|
||||
"indices": [
|
||||
{"ts_code": code, "trade_date": "20260721", "pct_chg": 0.1}
|
||||
for code in ("000001.SH", "399001.SZ", "399006.SZ")
|
||||
],
|
||||
}
|
||||
sector = {"taxonomy": "sw_l2", "precise": False, "error": "行业日线尚未返回"}
|
||||
stock = {
|
||||
"code": "002141", "name": "贤丰控股", "trade_date": trade_date,
|
||||
"data_source": "tushare", "realtime": False, "precise": True,
|
||||
"amount_billion": 20, "turnover_rate": 8.5, "seal_amount_million": 0,
|
||||
"open_times": 0, "change": 2.4, "streak": 0, "status": "普通",
|
||||
}
|
||||
|
||||
automatic = DashboardService._heaven_line_checks(
|
||||
trade_date, dashboard, history, index_context, sector, stock, "closed", {}
|
||||
)
|
||||
self.assertEqual(
|
||||
[item["line"] for item in automatic if not item["passed"]], [3, 4, 6]
|
||||
)
|
||||
|
||||
manual = {
|
||||
"sector_name": "元件", "sector_up_count": 18, "sector_down_count": 42,
|
||||
"sector_coverage": 96, "sector_member_equal_change": -2.2,
|
||||
"sector_change": -2.6, "sector_leading_pct": 3.1,
|
||||
"index_sh_change": -0.9, "index_sz_change": -1.4, "index_cy_change": -1.8,
|
||||
}
|
||||
merged = DashboardService._apply_heaven_manual_data(
|
||||
dashboard, index_context, sector, stock, manual, "closed", trade_date, "002141"
|
||||
)
|
||||
repaired = DashboardService._heaven_line_checks(
|
||||
trade_date, merged[0], history, merged[1], merged[2], merged[3], "closed", manual
|
||||
)
|
||||
|
||||
self.assertTrue(all(item["passed"] for item in repaired))
|
||||
self.assertEqual(
|
||||
[item["line"] for item in repaired if item["status"] == "manual"], [3, 4, 6]
|
||||
)
|
||||
self.assertEqual(repaired[5]["line_value"], 8)
|
||||
self.assertAlmostEqual(repaired[5]["score"], (-0.9 - 1.4 - 1.8) / 3 / 3, places=3)
|
||||
|
||||
def test_sector_inner_and_outer_have_independent_quality_gates(self):
|
||||
trade_date = "20260722"
|
||||
dashboard = {
|
||||
"overview": {
|
||||
"sentiment_score": 30, "seal_rate": 50, "amount_billion": 15000,
|
||||
"up_count": 2000, "down_count": 3000,
|
||||
"limit_up_count": 40, "limit_down_count": 80,
|
||||
},
|
||||
"limits": [], "sectors": [], "sector_rotation": [],
|
||||
}
|
||||
history = [{"amount_billion": 14800}, {"amount_billion": 14900}]
|
||||
indices = {
|
||||
"trade_date": trade_date, "source": "tushare", "realtime": False,
|
||||
"precise": True,
|
||||
"indices": [
|
||||
{"ts_code": code, "trade_date": trade_date, "pct_chg": -1}
|
||||
for code in ("000001.SH", "399001.SZ", "399006.SZ")
|
||||
],
|
||||
"aggregate": {"average_pct_chg": -1},
|
||||
}
|
||||
sector = {
|
||||
"name": "元件", "code": "801083.SI", "taxonomy": "sw_l2",
|
||||
"trade_date": trade_date, "realtime": False, "finalized": True,
|
||||
"inner_precise": True, "outer_precise": False, "precise": False,
|
||||
"coverage": 98, "up_count": 19, "down_count": 46,
|
||||
"member_equal_change": -2.15, "leading_pct": 5.2,
|
||||
"outer_error": "申万日线尚未发布", "source": "tushare_member_daily",
|
||||
}
|
||||
stock = {
|
||||
"code": "002141", "trade_date": trade_date, "precise": True,
|
||||
"realtime": False, "data_source": "tushare", "amount_billion": 10,
|
||||
"turnover_rate": 5, "seal_amount_million": 0, "open_times": 0,
|
||||
"change": 2, "streak": 0, "status": "普通",
|
||||
}
|
||||
|
||||
checks = DashboardService._heaven_line_checks(
|
||||
trade_date, dashboard, history, indices, sector, stock, "closed", {}
|
||||
)
|
||||
|
||||
self.assertTrue(checks[2]["passed"])
|
||||
self.assertFalse(checks[3]["passed"])
|
||||
self.assertIn("申万日线尚未发布", checks[3]["reasons"])
|
||||
|
||||
manual = {"sector_change": -3.85}
|
||||
merged = DashboardService._apply_heaven_manual_data(
|
||||
dashboard, indices, sector, stock, manual, "closed", trade_date, "002141"
|
||||
)
|
||||
repaired = DashboardService._heaven_line_checks(
|
||||
trade_date, merged[0], history, merged[1], merged[2], merged[3], "closed", manual
|
||||
)
|
||||
self.assertTrue(repaired[3]["passed"])
|
||||
self.assertEqual(repaired[3]["status"], "manual")
|
||||
self.assertEqual(
|
||||
[field["key"] for field in repaired[3]["fields"] if field["manual"]],
|
||||
["sector_change"],
|
||||
)
|
||||
|
||||
missing_leader_sector = {**merged[2], "leading_pct": None}
|
||||
still_blocked = DashboardService._heaven_line_checks(
|
||||
trade_date,
|
||||
merged[0],
|
||||
history,
|
||||
merged[1],
|
||||
missing_leader_sector,
|
||||
merged[3],
|
||||
"closed",
|
||||
manual,
|
||||
)
|
||||
self.assertFalse(still_blocked[3]["passed"])
|
||||
self.assertIn("需补充:行业领涨股涨跌幅", still_blocked[3]["reasons"])
|
||||
|
||||
|
||||
class ShenwanMembershipTests(unittest.TestCase):
|
||||
@patch.object(TushareClient, "query")
|
||||
def test_latest_effective_membership_wins_over_stale_is_new_row(self, query: MagicMock):
|
||||
stale_y = {
|
||||
"l1_code": "801010.SI", "l1_name": "农林牧渔",
|
||||
"l2_code": "801018.SI", "l2_name": "动物保健Ⅱ",
|
||||
"l3_code": "850181.SI", "l3_name": "动物保健Ⅲ",
|
||||
"ts_code": "002141.SZ", "in_date": "20240730", "out_date": None, "is_new": "Y",
|
||||
}
|
||||
current_y = {
|
||||
"l1_code": "801080.SI", "l1_name": "电子",
|
||||
"l2_code": "801083.SI", "l2_name": "元件",
|
||||
"l3_code": "850822.SI", "l3_name": "印制电路板",
|
||||
"ts_code": "002141.SZ", "in_date": "20260701", "out_date": None, "is_new": "Y",
|
||||
}
|
||||
closed_n = {**stale_y, "out_date": "20260630", "is_new": "N"}
|
||||
query.side_effect = lambda _api, params, _fields: (
|
||||
[stale_y, current_y] if params["is_new"] == "Y" else [closed_n]
|
||||
)
|
||||
|
||||
industry = TushareClient("token").sw_stock_industry("002141.SZ", "20260722")
|
||||
|
||||
self.assertEqual(industry["l2_code"], "801083.SI")
|
||||
self.assertEqual(industry["l2_name"], "元件")
|
||||
|
||||
|
||||
class RealtimeAggregatorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
WebRealtimeAggregator._response_cache.clear()
|
||||
|
||||
@staticmethod
|
||||
def _response(payload: dict) -> MagicMock:
|
||||
response = MagicMock()
|
||||
response.headers.get.return_value = "application/json"
|
||||
response.read.return_value = json.dumps(payload).encode("utf-8")
|
||||
context = MagicMock()
|
||||
context.__enter__.return_value = response
|
||||
return context
|
||||
|
||||
@patch("realtime_aggregator.urllib.request.urlopen")
|
||||
def test_transport_failure_is_retried(self, urlopen: MagicMock):
|
||||
urlopen.side_effect = [
|
||||
http.client.RemoteDisconnected("temporary disconnect"),
|
||||
self._response({"rc": 0, "data": {"diff": []}}),
|
||||
]
|
||||
aggregator = WebRealtimeAggregator(retry_delay_seconds=0)
|
||||
|
||||
payload = aggregator._get_json("https://example.test", {}, "https://example.test")
|
||||
|
||||
self.assertEqual(payload["rc"], 0)
|
||||
self.assertEqual(urlopen.call_count, 2)
|
||||
|
||||
@patch("realtime_aggregator.urllib.request.urlopen")
|
||||
def test_recent_success_is_used_after_retries_fail(self, urlopen: MagicMock):
|
||||
aggregator = WebRealtimeAggregator(retry_delay_seconds=0)
|
||||
urlopen.return_value = self._response({"rc": 0, "data": {"diff": []}})
|
||||
aggregator._get_json("https://example.test", {}, "https://example.test")
|
||||
urlopen.side_effect = http.client.RemoteDisconnected("temporary disconnect")
|
||||
|
||||
payload = aggregator._get_json("https://example.test", {}, "https://example.test")
|
||||
|
||||
self.assertIn("_aggregate_cache", payload)
|
||||
self.assertEqual(urlopen.call_count, 4)
|
||||
|
||||
@patch.object(WebRealtimeAggregator, "_get_text")
|
||||
def test_tencent_indices_include_verifiable_quote_times(self, get_text: MagicMock):
|
||||
def quote_line(
|
||||
symbol: str,
|
||||
name: str,
|
||||
code: str,
|
||||
price: str,
|
||||
previous_close: str,
|
||||
quote_time: str,
|
||||
change_amount: str,
|
||||
change: str,
|
||||
amount: str,
|
||||
) -> str:
|
||||
fields = [""] * 38
|
||||
fields[1] = name
|
||||
fields[2] = code
|
||||
fields[3] = price
|
||||
fields[4] = previous_close
|
||||
fields[5] = price
|
||||
fields[30] = quote_time
|
||||
fields[31] = change_amount
|
||||
fields[32] = change
|
||||
fields[33] = price
|
||||
fields[34] = price
|
||||
fields[37] = amount
|
||||
return f'v_{symbol}="{"~".join(fields)}";'
|
||||
|
||||
get_text.return_value = (
|
||||
'\n'.join(
|
||||
[
|
||||
quote_line("sh000001", "上证指数", "000001", "3796.28", "3764.15", "20260720155402", "32.13", "0.85", "129465190"),
|
||||
quote_line("sz399001", "深证成指", "399001", "13610.23", "13706.88", "20260720155330", "-96.65", "-0.71", "140747525"),
|
||||
quote_line("sz399006", "创业板指", "399006", "3443.10", "3428.63", "20260720155345", "14.47", "0.42", "67120487"),
|
||||
]
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
rows = WebRealtimeAggregator().tencent_indices()
|
||||
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(rows[0]["source"], "tencent_qt")
|
||||
self.assertEqual(rows[0]["quote_time"][:10], "2026-07-20")
|
||||
self.assertAlmostEqual(rows[0]["amount_billion"], 12946.52)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_method(name: str):
|
||||
source = Path("server.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
dashboard_service = next(
|
||||
node for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "DashboardService"
|
||||
)
|
||||
method = next(
|
||||
node for node in dashboard_service.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == name
|
||||
)
|
||||
module = ast.Module(body=[method], type_ignores=[])
|
||||
namespace = {"datetime": datetime, "Any": object}
|
||||
exec(compile(ast.fix_missing_locations(module), "server.py", "exec"), namespace)
|
||||
return namespace[name]
|
||||
|
||||
|
||||
MARKET_MODE = load_method("_heaven_market_mode")
|
||||
QUALITY_ISSUES = load_method("_heaven_trend_quality_issues")
|
||||
TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
class MarketModeTests(unittest.TestCase):
|
||||
def test_closed_rt_snapshot_is_closed_not_intraday(self):
|
||||
dashboard = {"meta": {"realtime": True, "market_status": "closed"}}
|
||||
now = datetime(2026, 7, 20, 16, 27, tzinfo=TZ)
|
||||
self.assertEqual(MARKET_MODE("20260720", dashboard, now), "closed")
|
||||
|
||||
def test_trading_snapshot_is_intraday(self):
|
||||
dashboard = {"meta": {"realtime": True, "market_status": "trading"}}
|
||||
now = datetime(2026, 7, 20, 10, 30, tzinfo=TZ)
|
||||
self.assertEqual(MARKET_MODE("20260720", dashboard, now), "intraday")
|
||||
|
||||
def test_historical_date_is_always_historical(self):
|
||||
dashboard = {"meta": {"realtime": True, "market_status": "trading"}}
|
||||
now = datetime(2026, 7, 20, 10, 30, tzinfo=TZ)
|
||||
self.assertEqual(MARKET_MODE("20260717", dashboard, now), "historical")
|
||||
|
||||
@staticmethod
|
||||
def intraday_layers(trade_date: str):
|
||||
index_context = {
|
||||
"trade_date": trade_date,
|
||||
"precise": True,
|
||||
"realtime": True,
|
||||
"indices": [{"trade_date": trade_date}] * 3,
|
||||
}
|
||||
sector = {
|
||||
"trade_date": trade_date,
|
||||
"precise": True,
|
||||
"realtime": True,
|
||||
"taxonomy": "sw_l2",
|
||||
"schema_version": 3,
|
||||
"coverage": 100,
|
||||
"relative_turnover": 1.2,
|
||||
}
|
||||
stock = {
|
||||
"trade_date": trade_date,
|
||||
"code": "002141",
|
||||
"precise": True,
|
||||
"realtime": True,
|
||||
"turnover_source": "float_share",
|
||||
"activity_source": "historical_progress",
|
||||
}
|
||||
return index_context, sector, stock
|
||||
|
||||
@staticmethod
|
||||
def historical_layers(trade_date: str):
|
||||
index_context = {
|
||||
"trade_date": trade_date,
|
||||
"precise": True,
|
||||
"realtime": False,
|
||||
"source": "tushare",
|
||||
"indices": [{"trade_date": trade_date}] * 3,
|
||||
}
|
||||
sector = {
|
||||
"trade_date": trade_date,
|
||||
"precise": True,
|
||||
"realtime": False,
|
||||
"taxonomy": "sw_l2",
|
||||
"schema_version": 3,
|
||||
"source": "tushare_sw_daily+member_daily",
|
||||
"coverage": 97,
|
||||
}
|
||||
stock = {
|
||||
"trade_date": trade_date,
|
||||
"code": "002141",
|
||||
"precise": True,
|
||||
"realtime": False,
|
||||
"data_source": "tushare",
|
||||
}
|
||||
return index_context, sector, stock
|
||||
|
||||
def test_intraday_accepts_verified_realtime_layers(self):
|
||||
trade_date = "20260720"
|
||||
index_context, sector, stock = self.intraday_layers(trade_date)
|
||||
dashboard = {
|
||||
"meta": {
|
||||
"realtime": True,
|
||||
"market_status": "closed",
|
||||
"updated_at": datetime.now(TZ).isoformat(),
|
||||
}
|
||||
}
|
||||
issues = QUALITY_ISSUES(
|
||||
trade_date, dashboard, index_context, sector, stock, "intraday"
|
||||
)
|
||||
self.assertEqual(issues, [])
|
||||
|
||||
def test_intraday_sector_coverage_90_passes_89_blocks(self):
|
||||
trade_date = "20260720"
|
||||
index_context, sector, stock = self.intraday_layers(trade_date)
|
||||
dashboard = {"meta": {"realtime": True, "market_status": "closed"}}
|
||||
sector["coverage"] = 90
|
||||
self.assertEqual(
|
||||
QUALITY_ISSUES(trade_date, dashboard, index_context, sector, stock, "intraday"),
|
||||
[],
|
||||
)
|
||||
sector["coverage"] = 89
|
||||
issues = QUALITY_ISSUES(
|
||||
trade_date, dashboard, index_context, sector, stock, "intraday"
|
||||
)
|
||||
self.assertTrue(any("覆盖率" in issue for issue in issues))
|
||||
|
||||
def test_intraday_sector_requires_relative_turnover(self):
|
||||
trade_date = "20260720"
|
||||
index_context, sector, stock = self.intraday_layers(trade_date)
|
||||
sector["relative_turnover"] = 0
|
||||
issues = QUALITY_ISSUES(
|
||||
trade_date, {"meta": {}}, index_context, sector, stock, "intraday"
|
||||
)
|
||||
self.assertTrue(any("相对全市场换手" in issue for issue in issues))
|
||||
|
||||
def test_sector_must_use_shenwan_l2_taxonomy(self):
|
||||
trade_date = "20260720"
|
||||
index_context, sector, stock = self.intraday_layers(trade_date)
|
||||
sector["taxonomy"] = "ths"
|
||||
issues = QUALITY_ISSUES(
|
||||
trade_date, {"meta": {}}, index_context, sector, stock, "intraday"
|
||||
)
|
||||
self.assertTrue(any("申万二级" in issue for issue in issues))
|
||||
|
||||
def test_historical_accepts_official_daily_layers(self):
|
||||
trade_date = "20260717"
|
||||
index_context, sector, stock = self.historical_layers(trade_date)
|
||||
issues = QUALITY_ISSUES(
|
||||
trade_date, {"meta": {}}, index_context, sector, stock, "historical"
|
||||
)
|
||||
self.assertEqual(issues, [])
|
||||
|
||||
def test_historical_rejects_realtime_index_layer(self):
|
||||
trade_date = "20260717"
|
||||
index_context, sector, stock = self.historical_layers(trade_date)
|
||||
index_context["realtime"] = True
|
||||
issues = QUALITY_ISSUES(
|
||||
trade_date, {"meta": {}}, index_context, sector, stock, "historical"
|
||||
)
|
||||
self.assertTrue(any("指数层" in issue for issue in issues))
|
||||
|
||||
def test_historical_rejects_realtime_sector_layer(self):
|
||||
trade_date = "20260717"
|
||||
index_context, sector, stock = self.historical_layers(trade_date)
|
||||
sector["realtime"] = True
|
||||
issues = QUALITY_ISSUES(
|
||||
trade_date, {"meta": {}}, index_context, sector, stock, "historical"
|
||||
)
|
||||
self.assertTrue(any("行业层" in issue for issue in issues))
|
||||
|
||||
def test_historical_rejects_non_tushare_stock(self):
|
||||
trade_date = "20260717"
|
||||
index_context, sector, stock = self.historical_layers(trade_date)
|
||||
stock["data_source"] = "dashboard"
|
||||
issues = QUALITY_ISSUES(
|
||||
trade_date, {"meta": {}}, index_context, sector, stock, "historical"
|
||||
)
|
||||
self.assertTrue(any("个股层" in issue for issue in issues))
|
||||
|
||||
def test_stock_must_be_precise(self):
|
||||
trade_date = "20260717"
|
||||
index_context, sector, stock = self.historical_layers(trade_date)
|
||||
stock["precise"] = False
|
||||
issues = QUALITY_ISSUES(
|
||||
trade_date, {"meta": {}}, index_context, sector, stock, "historical"
|
||||
)
|
||||
self.assertTrue(any("个股层" in issue for issue in issues))
|
||||
|
||||
def test_closed_mode_ignores_nonessential_dashboard_status(self):
|
||||
trade_date = "20260720"
|
||||
index_context, sector, stock = self.historical_layers(trade_date)
|
||||
dashboard = {"meta": {"realtime": True, "market_status": "trading"}}
|
||||
issues = QUALITY_ISSUES(
|
||||
trade_date, dashboard, index_context, sector, stock, "closed"
|
||||
)
|
||||
self.assertEqual(issues, [])
|
||||
|
||||
def test_closed_mode_accepts_finalized_realtime_shenwan_snapshot(self):
|
||||
trade_date = "20260720"
|
||||
index_context, sector, stock = self.historical_layers(trade_date)
|
||||
sector.update({
|
||||
"realtime": True,
|
||||
"finalized": True,
|
||||
"inner_precise": True,
|
||||
"outer_precise": True,
|
||||
"relative_turnover": 1.2,
|
||||
})
|
||||
|
||||
issues = QUALITY_ISSUES(
|
||||
trade_date, {"meta": {}}, index_context, sector, stock, "closed"
|
||||
)
|
||||
|
||||
self.assertEqual(issues, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from tushare_client import TushareClient
|
||||
|
||||
|
||||
class FakeRealtimeClient(TushareClient):
|
||||
def query(self, api_name, params=None, fields=""):
|
||||
params = params or {}
|
||||
if api_name == "trade_cal":
|
||||
return [
|
||||
{
|
||||
"cal_date": "20260720",
|
||||
"is_open": 1,
|
||||
"pretrade_date": "20260717",
|
||||
}
|
||||
]
|
||||
if api_name == "stock_basic":
|
||||
return [
|
||||
{"ts_code": "000001.SZ", "name": "甲", "industry": "银行"},
|
||||
{"ts_code": "000002.SZ", "name": "乙", "industry": "地产"},
|
||||
{"ts_code": "000003.SZ", "name": "丙", "industry": "元器件"},
|
||||
]
|
||||
if api_name == "stk_limit":
|
||||
return [
|
||||
{"ts_code": "000001.SZ", "up_limit": 11.0, "down_limit": 9.0},
|
||||
{"ts_code": "000002.SZ", "up_limit": 22.0, "down_limit": 18.0},
|
||||
{"ts_code": "000003.SZ", "up_limit": 33.0, "down_limit": 27.0},
|
||||
]
|
||||
if api_name == "daily_basic":
|
||||
requested_code = str(params.get("ts_code") or "")
|
||||
rows = [
|
||||
{"ts_code": "000001.SZ", "trade_date": "20260717", "float_share": 1000},
|
||||
{"ts_code": "000002.SZ", "trade_date": "20260717", "float_share": 2000},
|
||||
{"ts_code": "000003.SZ", "trade_date": "20260717", "float_share": 3000},
|
||||
]
|
||||
return [row for row in rows if not requested_code or row["ts_code"] == requested_code]
|
||||
if api_name == "daily":
|
||||
return [
|
||||
{"ts_code": params.get("ts_code"), "trade_date": "20260713", "vol": 1000, "amount": 1},
|
||||
{"ts_code": params.get("ts_code"), "trade_date": "20260714", "vol": 1000, "amount": 1},
|
||||
{"ts_code": params.get("ts_code"), "trade_date": "20260715", "vol": 1000, "amount": 1},
|
||||
{"ts_code": params.get("ts_code"), "trade_date": "20260716", "vol": 1000, "amount": 1},
|
||||
{"ts_code": params.get("ts_code"), "trade_date": "20260717", "vol": 1000, "amount": 1},
|
||||
]
|
||||
if api_name == "limit_list_d":
|
||||
return [
|
||||
{
|
||||
"ts_code": "000001.SZ",
|
||||
"name": "甲",
|
||||
"industry": "银行",
|
||||
"close": 10.0,
|
||||
"pct_chg": 10.0,
|
||||
"amount": 100000000,
|
||||
"limit_times": 2,
|
||||
}
|
||||
]
|
||||
if api_name == "rt_k":
|
||||
rows = [
|
||||
{
|
||||
"ts_code": "000001.SZ", "name": "甲", "pre_close": 10.0,
|
||||
"open": 10.1, "high": 11.0, "low": 10.0, "close": 11.0,
|
||||
"vol": 1000, "amount": 100000000, "num": 10,
|
||||
},
|
||||
{
|
||||
"ts_code": "000002.SZ", "name": "乙", "pre_close": 20.0,
|
||||
"open": 19.5, "high": 20.0, "low": 18.0, "close": 18.0,
|
||||
"vol": 2000, "amount": 200000000, "num": 20,
|
||||
},
|
||||
{
|
||||
"ts_code": "000003.SZ", "name": "丙", "pre_close": 30.0,
|
||||
"open": 31.0, "high": 33.0, "low": 30.0, "close": 32.0,
|
||||
"vol": 3000, "amount": 300000000, "num": 30,
|
||||
},
|
||||
]
|
||||
requested = {
|
||||
code for code in str(params.get("ts_code") or "").split(",") if code
|
||||
}
|
||||
return [row for row in rows if row["ts_code"] in requested]
|
||||
raise AssertionError(f"Unexpected API call: {api_name} {params}")
|
||||
|
||||
|
||||
class RealtimeDashboardTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
TushareClient._realtime_reference_cache.clear()
|
||||
TushareClient._capital_cache.clear()
|
||||
TushareClient._latest_realtime_market.clear()
|
||||
TushareClient._stock_activity_cache.clear()
|
||||
self.client = FakeRealtimeClient("test-token")
|
||||
|
||||
def test_realtime_dashboard_classifies_pools_and_units(self):
|
||||
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
||||
|
||||
self.assertTrue(dashboard["meta"]["realtime"])
|
||||
self.assertEqual(dashboard["meta"]["quote_count"], 3)
|
||||
self.assertEqual(dashboard["overview"]["limit_up_count"], 1)
|
||||
self.assertEqual(dashboard["overview"]["limit_down_count"], 1)
|
||||
self.assertEqual(dashboard["overview"]["broken_count"], 1)
|
||||
self.assertEqual(dashboard["overview"]["amount_billion"], 6.0)
|
||||
self.assertEqual(dashboard["limits"][0]["streak"], 3)
|
||||
self.assertEqual(dashboard["limits"][0]["amount_billion"], 1.0)
|
||||
|
||||
def test_realtime_stock_quote_uses_cached_industry(self):
|
||||
self.client._load_realtime_reference("20260720", "20260717")
|
||||
quote = self.client.realtime_stock_quote("000003.SZ")
|
||||
|
||||
self.assertEqual(quote["name"], "丙")
|
||||
self.assertEqual(quote["sector"], "元器件")
|
||||
self.assertAlmostEqual(quote["change"], 6.6667)
|
||||
self.assertEqual(quote["amount_billion"], 3.0)
|
||||
self.assertAlmostEqual(quote["turnover_rate"], 0.01)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user