rebuild(stage-11): deliver deterministic heaven workflows
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { expect, test } = require("@playwright/test");
|
||||
|
||||
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-11");
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
const lines = Array.from({ length: 6 }, (_, index) => ({
|
||||
position: index + 1,
|
||||
position_name: ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"][index],
|
||||
talent: ["地", "地", "人", "人", "天", "天"][index],
|
||||
layer: index % 2 ? "外" : "内",
|
||||
role: ["个股内核", "个股外显", "行业内核", "行业外显", "市场内核", "指数外显"][index],
|
||||
score: 0.42,
|
||||
value: index % 2 ? 8 : 7,
|
||||
moving: false,
|
||||
}));
|
||||
|
||||
const hexagram = {
|
||||
name: "泰",
|
||||
text: "天地交而万物通。",
|
||||
inner_trigram: "乾",
|
||||
outer_trigram: "坤",
|
||||
lines,
|
||||
transformed: { name: "泰", inner_trigram: "乾", outer_trigram: "坤" },
|
||||
};
|
||||
|
||||
const fortune = {
|
||||
date: "2026-07-30",
|
||||
lunar_date: "农历六月十七",
|
||||
pillars: { year: "丙午", month: "乙未", day: "乙巳" },
|
||||
solar_term: { current: "大暑", next: "立秋" },
|
||||
phrase: "湿热交蒸·燥中夹滞",
|
||||
movement: { element: "水", tendency: "太过" },
|
||||
six_qi: { sitian: "少阴君火", zaiquan: "阳明燥金", step: 4, step_name: "四之气", host: "太阴湿土", guest: "少阳相火" },
|
||||
layers: [
|
||||
{ label: "年纲", dominant: "火", summary: "水运为纲,司天少阴君火" },
|
||||
{ label: "客主加临", dominant: "土", summary: "客少阳相火加临主太阴湿土" },
|
||||
{ label: "日辰触发", dominant: "木", summary: "乙巳日,只作轻量触发" },
|
||||
],
|
||||
personal: { day_master_element: "木", tone: "当日主气与个人生扶倾向交错", notice: "个人信息只用于本地派生计算。" },
|
||||
sector_catalog: ["木", "火", "土", "金", "水"].map((element) => ({ element, industries: ["行业一", "行业二"] })),
|
||||
notice: "五行气场是传统历法与市场行为的象征性观察,不代表可验证的因果关系。",
|
||||
};
|
||||
|
||||
const trendResult = {
|
||||
trade_date: "2026-07-30",
|
||||
stock: { identifier: "002141.SZ", code: "002141", name: "贤丰控股" },
|
||||
sector: { name: "元件" },
|
||||
hexagram,
|
||||
momentum_score: 42,
|
||||
momentum_label: "势起未极",
|
||||
checks: lines.map((line) => ({ position: line.position, position_name: line.position_name, role: line.role, passed: true, source: "automatic", message: "自动数据通过" })),
|
||||
};
|
||||
|
||||
function reading(id, mode, result, interpretation = "") {
|
||||
return { id, mode, date: "2026-07-30", subject_key: "", result, interpretation, status: interpretation ? "complete" : "pending", created_at: "2026-07-30T10:00:00+08:00" };
|
||||
}
|
||||
|
||||
async function mockHeaven(page) {
|
||||
const historical = reading(90, "trend", trendResult, "历史解势内容");
|
||||
await page.route("**/api/heaven/setup?*", (route) => route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ date: "2026-07-30", fortune, daily_fortune: null, history: [historical] }),
|
||||
}));
|
||||
await page.route("**/api/heaven/trend/load", (route) => route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ready: true, reading_id: 101, result: trendResult }),
|
||||
}));
|
||||
await page.route("**/api/heaven/fortune", (route) => route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ reused: false, reading: reading(102, "fortune", fortune) }),
|
||||
}));
|
||||
await page.route("**/api/heaven/heart/line", async (route) => {
|
||||
const payload = route.request().postDataJSON();
|
||||
const values = [...payload.values, payload.values.length % 2 ? 8 : 7];
|
||||
await route.fulfill({ contentType: "application/json", body: JSON.stringify({ position: values.length, value: values.at(-1), faces: ["front", "back", "front"], values }) });
|
||||
});
|
||||
await page.route("**/api/heaven/heart/complete", (route) => route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ reading_id: 103, result: { date: "2026-07-30", hexagram, notice: "仅供自我观察。" } }),
|
||||
}));
|
||||
await page.route("**/api/heaven/interpret", async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
await route.fulfill({
|
||||
contentType: "application/x-ndjson",
|
||||
body: `${JSON.stringify({ type: "delta", content: "第一段解读" })}\n${JSON.stringify({ type: "delta", content: ",第二段解读" })}\n${JSON.stringify({ type: "done" })}\n`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("stage 11 Heaven workflows, animations and responsive layouts", async ({ page }) => {
|
||||
const consoleErrors = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text());
|
||||
});
|
||||
await mockHeaven(page);
|
||||
await authenticate(page, "stage4admin", "Stage4-pass-123!");
|
||||
await page.goto("/workspace/heaven");
|
||||
await page.getByRole("button", { name: "夜间" }).click();
|
||||
await expect(page.getByText("请输入股票代码或股票名称", { exact: true })).toBeVisible();
|
||||
await page.getByLabel("股票代码或名称").fill("贤丰控股");
|
||||
await page.getByRole("button", { name: "载入", exact: true }).click();
|
||||
await expect(page.getByText("当前标的:")).toContainText("贤丰控股");
|
||||
await expect(page.getByText("泰", { exact: true }).first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "解势", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "解势" });
|
||||
await expect(dialog).toBeVisible();
|
||||
const box = await dialog.boundingBox();
|
||||
expect(box.width).toBeGreaterThan(700);
|
||||
expect(box.width / box.height).toBeGreaterThan(1.15);
|
||||
const loadingGeometry = await page.locator(".heaven-loading-stage").evaluate((stage) => {
|
||||
const canvas = stage.querySelector("canvas").getBoundingClientRect();
|
||||
const caption = stage.querySelector("p").getBoundingClientRect();
|
||||
return { canvasBottom: canvas.bottom, captionTop: caption.top };
|
||||
});
|
||||
expect(loadingGeometry.canvasBottom).toBeLessThanOrEqual(loadingGeometry.captionTop);
|
||||
await page.screenshot({ path: path.join(evidence, "heaven-interpret-loading-dark.jpg"), type: "jpeg", quality: 82 });
|
||||
await expect(page.getByText("第一段解读,第二段解读", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "历史记录", exact: true }).last().click();
|
||||
await page.getByRole("button", { name: /2026-07-30 · 贤丰控股/ }).last().click();
|
||||
await expect(page.getByText("历史解势内容", { exact: true })).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "关闭" }).click();
|
||||
|
||||
await page.locator(".heaven-mode-tabs button").filter({ hasText: "观气" }).click();
|
||||
await expect(page.getByText("湿热交蒸·燥中夹滞", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("壹", { exact: true })).toBeVisible();
|
||||
await page.locator(".heaven-section-toggle").click();
|
||||
await expect(page.getByText("行业一 · 行业二", { exact: true }).first()).toBeVisible();
|
||||
|
||||
await page.clock.install();
|
||||
await page.locator(".heaven-mode-tabs button").filter({ hasText: "观心" }).click();
|
||||
await page.getByRole("button", { name: "开始静心", exact: true }).click();
|
||||
await page.clock.fastForward(1200);
|
||||
await expect(page.getByText("吸", { exact: true })).toBeVisible();
|
||||
await page.clock.fastForward(45_000);
|
||||
await page.getByRole("button", { name: "静心完成,开始起卦" }).click();
|
||||
await expect(page.locator(".heart-cast-line.is-revealed")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "投掷初爻" }).click();
|
||||
await expect(page.locator(".heart-cast-line.is-revealed")).toHaveCount(1);
|
||||
await expect(page.locator(".heart-cast-line").filter({ hasText: "未得" })).toHaveCount(5);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
|
||||
await page.screenshot({ path: path.join(evidence, "heaven-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
|
||||
|
||||
await page.getByRole("button", { name: "日间" }).click();
|
||||
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, "heaven-light-390x844.jpg"), type: "jpeg", quality: 82 });
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("nonmembers retain the complete grey Heaven structure", async ({ page }) => {
|
||||
await mockHeaven(page);
|
||||
await authenticate(page, "stage4user", "Stage4-user-123!");
|
||||
await page.goto("/workspace/heaven");
|
||||
await expect(page.getByText("问天仅对会员开放")).toBeVisible();
|
||||
await expect(page.locator(".heaven-mode-tabs").getByText("观势", { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel("股票代码或名称")).toBeDisabled();
|
||||
await expect(page.locator(".locked-content")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.bootstrap.settings import PROJECT_ROOT
|
||||
from backend.data.contracts import (
|
||||
DataSource,
|
||||
DataUsage,
|
||||
MarketEntity,
|
||||
ObservationMetadata,
|
||||
ProviderResult,
|
||||
SnapshotState,
|
||||
)
|
||||
from backend.data.heaven import realtime_payload
|
||||
from backend.data.repository import MarketRepository
|
||||
from backend.database import MIGRATIONS, Database, MigrationRunner
|
||||
from backend.features.accounts.models import MembershipRecord, Principal, UserRecord
|
||||
from backend.features.heaven import fortune, trend
|
||||
from backend.features.heaven.hexagram import from_lines
|
||||
from backend.features.heaven.prompt import messages
|
||||
from backend.features.heaven.repository import HeavenRepository
|
||||
from backend.features.heaven.service import HeavenService
|
||||
|
||||
ICHING = PROJECT_ROOT / "config" / "heaven" / "iching_zh.json"
|
||||
|
||||
|
||||
def _payload() -> dict:
|
||||
return {
|
||||
"trade_date": "2026-07-22",
|
||||
"mode": "historical",
|
||||
"stock": {
|
||||
"identifier": "601318.SH",
|
||||
"code": "601318",
|
||||
"name": "中国平安",
|
||||
"trade_date": "2026-07-22",
|
||||
"quote_kind": "daily",
|
||||
"change": 2.5,
|
||||
"amount_percentile": 88,
|
||||
"turnover_rate": 1.2,
|
||||
"seal_amount_million": 0,
|
||||
"open_times": 0,
|
||||
"streak": 0,
|
||||
"status": "普通",
|
||||
},
|
||||
"sector": {
|
||||
"name": "保险Ⅱ",
|
||||
"code": "801194.SI",
|
||||
"taxonomy": "申万二级",
|
||||
"trade_date": "2026-07-22",
|
||||
"quote_kind": "daily",
|
||||
"change": 2.2,
|
||||
"up_count": 5,
|
||||
"down_count": 0,
|
||||
"member_count": 5,
|
||||
"quoted_count": 5,
|
||||
"coverage": 1,
|
||||
"member_equal_change": 1.8,
|
||||
"leader": "新华保险",
|
||||
"leading_pct": 4.5,
|
||||
},
|
||||
"market": {
|
||||
"trade_date": "2026-07-22",
|
||||
"quote_kind": "daily",
|
||||
"sentiment_score": 42,
|
||||
"seal_rate": 73.9,
|
||||
"amount_billion": 11800,
|
||||
"average_amount_billion": 10500,
|
||||
"up_count": 3180,
|
||||
"down_count": 1730,
|
||||
"limit_up_count": 68,
|
||||
"limit_down_count": 6,
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"identifier": "000001.SH",
|
||||
"trade_date": "2026-07-22",
|
||||
"quote_kind": "daily",
|
||||
"change": 0.6,
|
||||
},
|
||||
{
|
||||
"identifier": "399001.SZ",
|
||||
"trade_date": "2026-07-22",
|
||||
"quote_kind": "daily",
|
||||
"change": 1.1,
|
||||
},
|
||||
{
|
||||
"identifier": "399006.SZ",
|
||||
"trade_date": "2026-07-22",
|
||||
"quote_kind": "daily",
|
||||
"change": 1.4,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _intraday_payload() -> dict:
|
||||
payload = _payload()
|
||||
payload["mode"] = "intraday"
|
||||
payload["stock"].update(
|
||||
quote_kind="realtime",
|
||||
turnover_relative=1.2,
|
||||
volume_activity_ratio=1.15,
|
||||
)
|
||||
payload["sector"].update(quote_kind="realtime", relative_turnover=1.1)
|
||||
payload["market"]["quote_kind"] = "realtime"
|
||||
for row in payload["indices"]:
|
||||
row["quote_kind"] = "realtime"
|
||||
return payload
|
||||
|
||||
|
||||
def _database(tmp_path: Path) -> Database:
|
||||
database = Database(tmp_path / "heaven.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
with database.transaction() as connection:
|
||||
for user_id in (1, 2):
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
id, username, username_key, password_hash, is_admin,
|
||||
status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'hash', 0, 'active', 'now', 'now')
|
||||
""",
|
||||
(user_id, f"user{user_id}", f"user{user_id}"),
|
||||
)
|
||||
return database
|
||||
|
||||
|
||||
def _principal() -> Principal:
|
||||
now = datetime.now(UTC)
|
||||
return Principal(
|
||||
"token",
|
||||
"csrf",
|
||||
UserRecord(1, "user1", "user1", "hash", False, "active", now, now),
|
||||
MembershipRecord(1, "active", None, True, 50, now, 1),
|
||||
)
|
||||
|
||||
|
||||
def _result(rows: list[dict]) -> ProviderResult:
|
||||
return ProviderResult(
|
||||
tuple(rows),
|
||||
ObservationMetadata(
|
||||
source=DataSource.TUSHARE,
|
||||
observed_at=datetime(2026, 7, 30, 10, tzinfo=UTC),
|
||||
unit="mixed",
|
||||
adjustment="not_applicable",
|
||||
freshness_seconds=0,
|
||||
coverage=1,
|
||||
state=SnapshotState.REALTIME,
|
||||
usage=DataUsage.CALCULATION,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_small_sector_with_five_of_five_quotes_passes_all_gates() -> None:
|
||||
result = trend.calculate(_payload(), ICHING)
|
||||
|
||||
assert len(result["hexagram"]["lines"]) == 6
|
||||
assert all(item["passed"] for item in result["checks"])
|
||||
assert result["checks"][2]["message"].endswith("5/5")
|
||||
|
||||
|
||||
def test_missing_one_formal_index_fails_closed() -> None:
|
||||
payload = _payload()
|
||||
payload["indices"].pop()
|
||||
|
||||
with pytest.raises(trend.TrendDataError) as captured:
|
||||
trend.calculate(payload, ICHING)
|
||||
|
||||
assert captured.value.checks[5]["passed"] is False
|
||||
assert "三大指数" in captured.value.checks[5]["message"]
|
||||
|
||||
|
||||
def test_intraday_payload_requires_real_activity_inputs() -> None:
|
||||
result = trend.calculate(_intraday_payload(), ICHING)
|
||||
assert all(item["passed"] for item in result["checks"])
|
||||
assert result["hexagram"]["lines"][0]["evidence"][1].startswith("相对换手")
|
||||
|
||||
missing = _intraday_payload()
|
||||
missing["stock"]["volume_activity_ratio"] = None
|
||||
with pytest.raises(trend.TrendDataError) as captured:
|
||||
trend.calculate(missing, ICHING)
|
||||
assert captured.value.checks[0]["passed"] is False
|
||||
|
||||
|
||||
def test_realtime_inputs_use_one_trade_date_and_official_sector_quote(tmp_path) -> None:
|
||||
database = _database(tmp_path)
|
||||
repository = MarketRepository()
|
||||
with database.transaction() as connection:
|
||||
repository.save_summary(
|
||||
connection,
|
||||
trade_date="2026-07-29",
|
||||
observed_at="2026-07-29T15:00:00+08:00",
|
||||
state="final",
|
||||
source="tushare",
|
||||
coverage=1,
|
||||
payload={
|
||||
"overview": {
|
||||
"up_count": 2500,
|
||||
"down_count": 2000,
|
||||
"limit_up": 40,
|
||||
"limit_down": 5,
|
||||
"broken": 10,
|
||||
"seal_rate": 80,
|
||||
"amount": 1_000_000_000_000,
|
||||
},
|
||||
"sentiment": {"score": 50},
|
||||
"limits": [],
|
||||
"yesterday_limits": [],
|
||||
},
|
||||
)
|
||||
stock_codes = ("601318.SH", "601319.SH", "601336.SH", "601601.SH")
|
||||
realtime = [
|
||||
{
|
||||
"ts_code": code,
|
||||
"name": f"保险{index}",
|
||||
"trade_time": "2026-07-30 10:00:00",
|
||||
"close": 10 + index,
|
||||
"pre_close": 10,
|
||||
"high": 10 + index,
|
||||
"low": 9.8,
|
||||
"open": 10,
|
||||
"vol": 1_000_000 + index * 100_000,
|
||||
"amount": 100_000_000 + index * 10_000_000,
|
||||
}
|
||||
for index, code in enumerate(stock_codes)
|
||||
]
|
||||
realtime.extend(
|
||||
{
|
||||
"ts_code": code,
|
||||
"name": code,
|
||||
"trade_time": "2026-07-30 10:00:00",
|
||||
"close": 101,
|
||||
"pre_close": 100,
|
||||
"high": 101,
|
||||
"low": 99,
|
||||
"open": 100,
|
||||
"vol": 1,
|
||||
"amount": 1,
|
||||
}
|
||||
for code in ("000001.SH", "399001.SZ", "399006.SZ")
|
||||
)
|
||||
raw = {
|
||||
"realtime": _result(realtime),
|
||||
"members": _result(
|
||||
[
|
||||
{
|
||||
"sector_code": "801194.SI",
|
||||
"sector_name": "保险Ⅱ",
|
||||
"ts_code": code,
|
||||
"name": f"保险{index}",
|
||||
}
|
||||
for index, code in enumerate(stock_codes)
|
||||
]
|
||||
),
|
||||
"capital": _result([{"ts_code": code, "float_share": 100_000} for code in stock_codes]),
|
||||
"stock_history": _result(
|
||||
[
|
||||
{"ts_code": "601318.SH", "trade_date": f"2026072{day}", "vol": 10_000}
|
||||
for day in range(5, 10)
|
||||
]
|
||||
),
|
||||
"price_limits": _result(
|
||||
[{"ts_code": code, "up_limit": 20, "down_limit": 5} for code in stock_codes]
|
||||
),
|
||||
"suspensions": _result([]),
|
||||
"sector_realtime": _result(
|
||||
[
|
||||
{
|
||||
"ts_code": "801194.SI",
|
||||
"name": "保险Ⅱ",
|
||||
"trade_time": "2026-07-30 10:00:00",
|
||||
"close": 102,
|
||||
"pre_close": 100,
|
||||
"pct_change": 2,
|
||||
}
|
||||
]
|
||||
),
|
||||
}
|
||||
payload = realtime_payload(
|
||||
database,
|
||||
repository,
|
||||
MarketEntity("stock", "601318.SH", "601318", "中国平安"),
|
||||
"2026-07-30",
|
||||
"2026-07-29",
|
||||
raw,
|
||||
datetime(2026, 7, 30, 10, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert payload["mode"] == "intraday"
|
||||
assert payload["stock"]["trade_date"] == "2026-07-30"
|
||||
assert payload["sector"]["change"] == 2
|
||||
assert payload["sector"]["quoted_count"] == 4
|
||||
assert len([row for row in payload["indices"] if row["change"] is not None]) == 3
|
||||
|
||||
|
||||
def test_manual_objective_sector_value_recomputes_without_overwriting_valid_data() -> None:
|
||||
payload = _payload()
|
||||
payload["sector"]["change"] = None
|
||||
original_leader_change = payload["sector"]["leading_pct"]
|
||||
|
||||
result = trend.calculate(
|
||||
payload,
|
||||
ICHING,
|
||||
{"sector": {"change": 2.8, "leading_pct": -9.9}},
|
||||
)
|
||||
|
||||
assert result["sector"]["change"] == 2.8
|
||||
assert result["sector"]["leading_pct"] == original_leader_change
|
||||
assert result["checks"][3]["source"] == "manual"
|
||||
|
||||
|
||||
def test_hexagram_is_deterministic_and_contains_only_six_lines() -> None:
|
||||
first = from_lines([7, 8, 9, 6, 7, 8], ICHING)
|
||||
second = from_lines([7, 8, 9, 6, 7, 8], ICHING)
|
||||
|
||||
assert first == second
|
||||
assert len(first["lines"]) == 6
|
||||
assert first["moving_lines"] == [3, 4]
|
||||
|
||||
|
||||
def test_fortune_uses_fixed_weight_total_and_composite_phrase() -> None:
|
||||
field = fortune.build("2026-07-30")
|
||||
|
||||
assert sum(item["score"] for item in field["balance"]) == 100
|
||||
assert "·" in field["phrase"]
|
||||
assert [item["label"] for item in field["layers"]] == ["年纲", "客主加临", "日辰触发"]
|
||||
|
||||
|
||||
def test_repository_history_is_account_isolated(tmp_path) -> None:
|
||||
database = _database(tmp_path)
|
||||
repository = HeavenRepository()
|
||||
with database.transaction() as connection:
|
||||
repository.add(
|
||||
connection,
|
||||
user_id=1,
|
||||
mode="heart",
|
||||
reading_date="2026-07-30",
|
||||
subject_key="",
|
||||
result={"a": 1},
|
||||
created_at="now",
|
||||
)
|
||||
repository.add(
|
||||
connection,
|
||||
user_id=2,
|
||||
mode="heart",
|
||||
reading_date="2026-07-30",
|
||||
subject_key="",
|
||||
result={"a": 2},
|
||||
created_at="now",
|
||||
)
|
||||
with database.read() as connection:
|
||||
first = repository.list(connection, 1, None, None)
|
||||
second = repository.list(connection, 2, None, None)
|
||||
|
||||
assert json.loads(first[0]["result_json"]) == {"a": 1}
|
||||
assert json.loads(second[0]["result_json"]) == {"a": 2}
|
||||
|
||||
|
||||
def test_daily_fortune_is_created_once_even_before_interpretation(tmp_path) -> None:
|
||||
database = _database(tmp_path)
|
||||
repository = HeavenRepository()
|
||||
with database.transaction() as connection:
|
||||
first, first_reused = repository.ensure_fortune(
|
||||
connection,
|
||||
user_id=1,
|
||||
reading_date="2026-07-30",
|
||||
result={"phrase": "初次结果"},
|
||||
created_at="now",
|
||||
)
|
||||
second, second_reused = repository.ensure_fortune(
|
||||
connection,
|
||||
user_id=1,
|
||||
reading_date="2026-07-30",
|
||||
result={"phrase": "不应覆盖"},
|
||||
created_at="later",
|
||||
)
|
||||
|
||||
assert first_reused is False
|
||||
assert second_reused is True
|
||||
assert first["id"] == second["id"]
|
||||
assert json.loads(second["result_json"])["phrase"] == "初次结果"
|
||||
|
||||
|
||||
def test_each_heart_cast_appends_exactly_one_line() -> None:
|
||||
service = HeavenService(
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(),
|
||||
ICHING,
|
||||
)
|
||||
|
||||
first = service.heart_line(_principal(), "2026-07-30", [])
|
||||
second = service.heart_line(_principal(), "2026-07-30", first["values"])
|
||||
|
||||
assert len(first["values"]) == 1
|
||||
assert len(second["values"]) == 2
|
||||
assert len(first["faces"]) == 3
|
||||
|
||||
|
||||
def test_fortune_prompt_never_contains_raw_birth_fields() -> None:
|
||||
result = fortune.build(
|
||||
"2026-07-30",
|
||||
SimpleNamespace(birth_date="1990-01-02", birth_time="03:04", gender="male"),
|
||||
)
|
||||
prompt = json.dumps(messages("fortune", result), ensure_ascii=False)
|
||||
|
||||
assert "1990-01-02" not in prompt
|
||||
assert "03:04" not in prompt
|
||||
assert '"gender"' not in prompt
|
||||
@@ -21,9 +21,9 @@ from backend.data.policy import DataPolicyError, DataSourcePolicy
|
||||
from backend.data.providers.ifind import IfindProvider
|
||||
from backend.data.providers.tushare import TushareProvider
|
||||
from backend.data.repository import MarketRepository
|
||||
from backend.data.sentiment import calculate_sentiment
|
||||
from backend.database.connection import Database
|
||||
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
||||
from backend.features.market.sentiment import calculate_sentiment
|
||||
from backend.features.market.snapshot import build_snapshot
|
||||
from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError
|
||||
from tests.support import run_scenario
|
||||
@@ -188,9 +188,7 @@ def test_ifind_top_level_tables_and_expired_access_token_are_handled() -> None:
|
||||
|
||||
def test_trade_context_keeps_real_snapshot_date(tmp_path) -> None:
|
||||
market = gateway(tmp_path)
|
||||
context = market.trade_context(
|
||||
"2026-07-30", datetime(2026, 7, 30, 9, 10, tzinfo=SHANGHAI)
|
||||
)
|
||||
context = market.trade_context("2026-07-30", datetime(2026, 7, 30, 9, 10, tzinfo=SHANGHAI))
|
||||
assert context.requested_date == "2026-07-30"
|
||||
assert context.actual_date == "2026-07-29"
|
||||
assert context.carried_forward is True
|
||||
@@ -288,6 +286,7 @@ def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> Non
|
||||
}
|
||||
for index, change in enumerate((10, 4, -10, 2, -2), start=1)
|
||||
]
|
||||
|
||||
def event(index, streak=1):
|
||||
return {
|
||||
"ts_code": f"00000{index}.SZ",
|
||||
@@ -298,6 +297,7 @@ def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> Non
|
||||
"amount": 100,
|
||||
"limit_times": streak,
|
||||
}
|
||||
|
||||
snapshot = build_snapshot(
|
||||
"2026-07-29",
|
||||
"2026-07-28",
|
||||
|
||||
@@ -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)
|
||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6, 7, 8, 9)
|
||||
assert {
|
||||
"users",
|
||||
"memberships",
|
||||
@@ -139,8 +139,9 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
||||
"mentor_messages",
|
||||
"llm_requests",
|
||||
"llm_attempts",
|
||||
"heaven_readings",
|
||||
} <= table_names(database)
|
||||
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (8, 7, 6, 5, 4, 3, 2, 1)
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (9, 8, 7, 6, 5, 4, 3, 2, 1)
|
||||
assert "users" not in table_names(database)
|
||||
assert "llm_models" not in table_names(database)
|
||||
|
||||
Reference in New Issue
Block a user