rebuild(stage-12): deliver private review workflows
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { expect, test } = require("@playwright/test");
|
||||
|
||||
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-12");
|
||||
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
|
||||
|
||||
async function authenticate(page, username, password) {
|
||||
await page.goto("/");
|
||||
await page.getByLabel("账号名").fill(username);
|
||||
await page.getByLabel("密码").fill(password);
|
||||
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||
await expect(page.locator(".sidebar, .field-error")).toBeVisible();
|
||||
if (!(await page.locator(".sidebar").isVisible())) {
|
||||
await page.getByRole("tab", { name: "注册" }).click();
|
||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||
}
|
||||
}
|
||||
|
||||
function reviewPayload(trades = []) {
|
||||
return {
|
||||
trade_date: "2026-07-30",
|
||||
watchlist: [{ identifier: "000001.SZ", code: "000001", name: "平安银行", sector: "银行", remark: "观察承接", pct_chg: 1.28, return_5d: 3.6, attention_score: 72 }],
|
||||
daily: { id: 1, code: "", stock_name: "", trade_date: "2026-07-30", summary: "缩量分化", content: "按计划等待", plan: "观察主线承接", updated_at: "now" },
|
||||
history: [{ id: 1, code: "", stock_name: "", trade_date: "2026-07-30", summary: "缩量分化", content: "按计划等待", plan: "观察主线承接", updated_at: "now" }],
|
||||
trades,
|
||||
trade_summary: { total: trades.length, realized: trades.length, win_rate: trades.length ? 100 : null, pnl_amount: trades.length ? 120 : null, average_position: trades.length ? 20 : null },
|
||||
};
|
||||
}
|
||||
|
||||
async function mockReview(page) {
|
||||
let trades = [];
|
||||
await page.route("**/api/review?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(reviewPayload(trades)) }));
|
||||
await page.route("**/api/review/trades", async (route) => {
|
||||
const value = route.request().postDataJSON();
|
||||
trades = [{ ...value, id: 11, action_label: "买入", emotion_label: "平静" }];
|
||||
await route.fulfill({ contentType: "application/json", body: JSON.stringify({ id: 11 }) });
|
||||
});
|
||||
await page.route("**/api/review/notes", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ id: 1 }) }));
|
||||
await page.route("**/api/review/watchlist/**", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ message: "已保存" }) }));
|
||||
await page.route("**/api/review/alerts?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ items: [{ id: 1, kind: "manual", title: "复核承接", content: "开盘后观察", available_date: "2026-07-30", code: "000001", is_read: false, due: true, created_at: "now" }], unread_count: 1, as_of: "2026-07-30" }) }));
|
||||
await page.route("**/api/review/alerts", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ id: 2 }) }));
|
||||
await page.route("**/api/review/assistant/messages", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(route.request().method() === "DELETE" ? { deleted: 0 } : []) }));
|
||||
await page.route("**/api/review/assistant/chat", (route) => route.fulfill({ contentType: "application/x-ndjson", body: `${JSON.stringify({ type: "delta", content: "【市场事实】温度回落。" })}\n${JSON.stringify({ type: "delta", content: "【条件化计划】等待承接确认。" })}\n${JSON.stringify({ type: "done" })}\n` }));
|
||||
await page.route("**/api/review/stock-notes/**", (route) => route.fulfill({ contentType: "application/json", body: "[]" }));
|
||||
}
|
||||
|
||||
test("stage 12 private review, alerts and assistant remain usable", async ({ page }) => {
|
||||
const consoleErrors = [];
|
||||
page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); });
|
||||
await mockReview(page);
|
||||
await authenticate(page, "stage4admin", "Stage4-pass-123!");
|
||||
await page.goto("/workspace/review");
|
||||
await expect(page.getByRole("heading", { name: "我的复盘" })).toBeVisible();
|
||||
await expect(page.getByText("平安银行", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("跟踪备注")).toHaveValue("观察承接");
|
||||
await expect(page.getByLabel("今日盘面一句话")).toHaveValue("缩量分化");
|
||||
await expect(page.getByLabel("今日做对了什么 / 做错了什么")).toHaveValue("按计划等待");
|
||||
await expect(page.getByLabel("明日策略")).toHaveValue("观察主线承接");
|
||||
|
||||
await page.getByRole("button", { name: "交易日志", exact: true }).click();
|
||||
const tradeDialog = page.getByRole("dialog", { name: "交易日志" });
|
||||
await tradeDialog.getByLabel("代码").fill("000001");
|
||||
await tradeDialog.getByLabel("名称").fill("平安银行");
|
||||
await tradeDialog.getByLabel("价格(元)").fill("10.20");
|
||||
await tradeDialog.getByLabel("仓位(%)").fill("20");
|
||||
await tradeDialog.getByLabel("盈亏(%)").fill("1.2");
|
||||
await tradeDialog.getByRole("button", { name: "保存", exact: true }).click();
|
||||
await expect(tradeDialog).toHaveCount(0);
|
||||
await expect(page.getByRole("status")).toContainText("交易记录已保存");
|
||||
await expect(page.locator(".trade-scroll").getByText("平安银行", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".dialog")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "提醒中心" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "提醒中心" })).toContainText("复核承接");
|
||||
await page.getByLabel("关闭").click();
|
||||
await page.getByRole("button", { name: "复盘助手" }).click();
|
||||
const assistant = page.getByRole("dialog", { name: "复盘助手" });
|
||||
await assistant.getByRole("button", { name: "市场位置" }).click();
|
||||
await assistant.getByRole("button", { name: "发送", exact: true }).click();
|
||||
await expect(assistant.getByText("【市场事实】温度回落。【条件化计划】等待承接确认。", { exact: true })).toBeVisible();
|
||||
await page.getByLabel("关闭").click();
|
||||
await page.getByRole("button", { name: "夜间" }).click();
|
||||
await page.screenshot({ path: path.join(evidence, "review-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
|
||||
await expect(page.locator(".mobile-nav")).toBeVisible();
|
||||
await page.screenshot({ path: path.join(evidence, "review-dark-390x844.jpg"), type: "jpeg", quality: 82 });
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("nonmembers see the complete disabled review assistant", async ({ page }) => {
|
||||
await mockReview(page);
|
||||
await authenticate(page, "stage4user", "Stage4-user-123!");
|
||||
await page.getByRole("button", { name: "复盘助手" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "复盘助手" });
|
||||
await expect(dialog.getByText("复盘助手仅对会员开放")).toBeVisible();
|
||||
await expect(dialog.getByPlaceholder(/询问市场位置/)).toBeDisabled();
|
||||
await expect(dialog.getByRole("button", { name: "市场位置" })).toBeVisible();
|
||||
});
|
||||
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
||||
database = Database(tmp_path / "app.db")
|
||||
runner = MigrationRunner(database)
|
||||
|
||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9)
|
||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
|
||||
assert {
|
||||
"users",
|
||||
"memberships",
|
||||
@@ -140,8 +140,23 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
||||
"llm_requests",
|
||||
"llm_attempts",
|
||||
"heaven_readings",
|
||||
"review_notes",
|
||||
"trade_entries",
|
||||
"alerts",
|
||||
"review_assistant_messages",
|
||||
} <= table_names(database)
|
||||
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (9, 8, 7, 6, 5, 4, 3, 2, 1)
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (
|
||||
10,
|
||||
9,
|
||||
8,
|
||||
7,
|
||||
6,
|
||||
5,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
)
|
||||
assert "users" not in table_names(database)
|
||||
assert "llm_models" not in table_names(database)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.database import MIGRATIONS, Database, MigrationRunner
|
||||
from backend.features.review.prompt import messages
|
||||
from backend.features.review.repository import ReviewRepository
|
||||
from backend.features.review.views import trade_summary
|
||||
|
||||
|
||||
def _database(tmp_path) -> Database:
|
||||
database = Database(tmp_path / "review.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
with database.transaction() as connection:
|
||||
connection.executemany(
|
||||
"""INSERT INTO users (
|
||||
id, username, username_key, password_hash, is_admin,
|
||||
status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'hash', 0, 'active', 'now', 'now')""",
|
||||
((1, "first", "first"), (2, "second", "second")),
|
||||
)
|
||||
return database
|
||||
|
||||
|
||||
def test_private_review_records_are_strictly_scoped_and_daily_notes_upsert(tmp_path) -> None:
|
||||
database = _database(tmp_path)
|
||||
repository = ReviewRepository()
|
||||
note = {
|
||||
"code": "",
|
||||
"stock_name": "",
|
||||
"trade_date": "2026-07-30",
|
||||
"summary": "缩量分化",
|
||||
"content": "追高一次",
|
||||
"plan": "等待承接",
|
||||
}
|
||||
trade = {
|
||||
"trade_date": "2026-07-30",
|
||||
"code": "000001",
|
||||
"name": "平安银行",
|
||||
"action": "buy",
|
||||
"price": 10.0,
|
||||
"quantity": 100,
|
||||
"position_pct": 20.0,
|
||||
"pnl_amount": None,
|
||||
"pnl_pct": None,
|
||||
"emotion": "calm",
|
||||
"tags": ["计划内"],
|
||||
"thesis": "承接",
|
||||
"execution": "符合",
|
||||
"id": None,
|
||||
}
|
||||
with database.transaction() as connection:
|
||||
repository.save_watch(
|
||||
connection,
|
||||
1,
|
||||
{
|
||||
"identifier": "000001.SZ",
|
||||
"name": "平安银行",
|
||||
"sector": "银行",
|
||||
},
|
||||
"now",
|
||||
)
|
||||
repository.save_watch(
|
||||
connection,
|
||||
2,
|
||||
{
|
||||
"identifier": "600000.SH",
|
||||
"name": "浦发银行",
|
||||
"sector": "银行",
|
||||
},
|
||||
"now",
|
||||
)
|
||||
assert repository.save_watch_remark(connection, 1, "000001.SZ", "观察承接")
|
||||
repository.save_watch(
|
||||
connection,
|
||||
1,
|
||||
{"identifier": "000001.SZ", "name": "平安银行", "sector": "银行"},
|
||||
"later",
|
||||
)
|
||||
note_id = repository.save_note(connection, 1, note, "now")
|
||||
note["summary"] = "更新后的盘面"
|
||||
assert repository.save_note(connection, 1, note, "later") == note_id
|
||||
trade_id = repository.save_trade(connection, 1, trade, "now")
|
||||
repository.save_alert(
|
||||
connection,
|
||||
1,
|
||||
{
|
||||
"kind": "manual",
|
||||
"title": "复核",
|
||||
"content": "看承接",
|
||||
"available_date": "2026-07-30",
|
||||
"code": "000001",
|
||||
"dedupe_key": "one",
|
||||
},
|
||||
"now",
|
||||
)
|
||||
repository.add_message(
|
||||
connection,
|
||||
1,
|
||||
{
|
||||
"role": "user",
|
||||
"content": "今天如何",
|
||||
"context_date": "2026-07-30",
|
||||
"request_id": None,
|
||||
"status": "complete",
|
||||
},
|
||||
"now",
|
||||
)
|
||||
with database.read() as connection:
|
||||
assert [row["identifier"] for row in repository.watchlist(connection, 1)] == ["000001.SZ"]
|
||||
assert repository.watchlist(connection, 1)[0]["remark"] == "观察承接"
|
||||
assert [row["identifier"] for row in repository.watchlist(connection, 2)] == ["600000.SH"]
|
||||
assert repository.note(connection, 1, "", "2026-07-30")["summary"] == "更新后的盘面"
|
||||
assert repository.note(connection, 2, "", "2026-07-30") is None
|
||||
assert [row["id"] for row in repository.trades(connection, 1)] == [trade_id]
|
||||
assert repository.trades(connection, 2) == ()
|
||||
assert len(repository.alerts(connection, 1, False)) == 1
|
||||
assert repository.alerts(connection, 2, False) == ()
|
||||
assert len(repository.messages(connection, 1)) == 1
|
||||
assert repository.messages(connection, 2) == ()
|
||||
|
||||
|
||||
def test_trade_summary_uses_only_realized_fields_and_keeps_zero_in_denominator() -> None:
|
||||
rows = [
|
||||
{"pnl_amount": None, "pnl_pct": None, "position_pct": None},
|
||||
{"pnl_amount": 100.0, "pnl_pct": None, "position_pct": 20.0},
|
||||
{"pnl_amount": None, "pnl_pct": 0.0, "position_pct": 40.0},
|
||||
{"pnl_amount": -20.0, "pnl_pct": -2.0, "position_pct": 60.0},
|
||||
]
|
||||
summary = trade_summary(rows)
|
||||
assert summary == {
|
||||
"total": 4,
|
||||
"realized": 3,
|
||||
"win_rate": 33.3,
|
||||
"pnl_amount": 80.0,
|
||||
"average_position": 40.0,
|
||||
}
|
||||
|
||||
|
||||
def test_review_prompt_separates_facts_records_inference_and_plan() -> None:
|
||||
prompt = messages({"market_facts": {"temperature": 42}}, [], "明天怎么看")
|
||||
system = prompt[0]["content"]
|
||||
assert all(label in system for label in ("市场事实", "用户记录", "推断", "条件化计划"))
|
||||
assert "不执行交易" in system
|
||||
Reference in New Issue
Block a user