summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md4
-rw-r--r--qa/README.md60
-rw-r--r--qa/driver/browser-test.js280
-rw-r--r--qa/driver/package-lock.json28
-rw-r--r--qa/driver/package.json9
-rwxr-xr-xqa/lifecycle-test.sh64
-rwxr-xr-xqa/run.sh133
-rwxr-xr-xqa/stub/fund-lab-python32
-rw-r--r--src/FundLab.Web/App.fs268
-rw-r--r--src/FundLab.Web/src/api.js16
-rw-r--r--src/FundLab.Web/src/styles.css61
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs177
12 files changed, 1127 insertions, 5 deletions
diff --git a/README.md b/README.md
index f68fbd9..1fcd88b 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# fund-lab
-私人单用户模拟基金平台。当前仓库处于阶段 1 骨架和阶段 2a 纯 F# 账本切片阶段;已接入基金创建/读取 PostgreSQL 持久化,尚未接入真实行情或真实下单。
+私人单用户模拟基金平台。已接入基金创建/读取 PostgreSQL 持久化与基于 akshare 的行情数据源(解释器缺失依赖时回退合成数据并显式标记 `_synthetic`),Web 端提供基金创建/读取界面;尚未接入真实下单。
## 本地验证
@@ -32,6 +32,6 @@ API 使用环境变量 `FUND_LAB_AUTH_TOKEN` 配置 Bearer token,使用 `FUND_
基金创建接口为 `POST /api/funds`,需要 `Idempotency-Key` 和 decimal 字符串字段;基金读取接口为 `GET /api/funds/{id}`。首次启动会自动创建所需表。
-运行时数据库、行情缓存、凭证和构建产物不应进入 Git。当前 `origin` 为 `/home/somhairle/git/fund-lab.git`;基线提交已独立发布并核对,最新 3a 持久化修改在完成最终验证前保持在本地工作树。
+运行时数据库、行情缓存、凭证和构建产物不应进入 Git。当前 `origin` 为 `/home/somhairle/git/fund-lab.git`;基线提交已独立发布并核对,最新 3c-1 Web 建档切片修改在完成最终验证前保持在本地工作树。
3a 收口测试还会启动独立临时 PostgreSQL 和真实 Kestrel 进程,覆盖 HTTP 创建、幂等重放/冲突、认证、畸形 JSON、金额边界、停止重启后的读取,以及首次 SQL 写入后故障的完整事务回滚。
diff --git a/qa/README.md b/qa/README.md
new file mode 100644
index 0000000..96aea72
--- /dev/null
+++ b/qa/README.md
@@ -0,0 +1,60 @@
+# fund-lab QA(3c-1)
+
+仓库内可复现的端到端浏览器 QA 入口。一条命令启动一次性 Postgres、API(桩行情)与 Vite,然后用无头 Chromium 驱动页面断言 3c-1 行为。
+
+## 运行
+
+```bash
+bash qa/run.sh
+```
+
+前提:`docker`(含本地 `postgres:16-alpine` 镜像)、`dotnet`、仓库内 `.tools/node-v22.23.2`(`node`/`npm`)。首次运行会在 `qa/driver/` 安装 `playwright-core`(node_modules 已被 .gitignore 覆盖)。
+
+`run.sh` 的行为约定:
+
+- 端口均为非默认:API `5095`、Vite `5175`、Postgres `55434`;容器名 `fund-lab-qa-pg-3c1`。
+- 启动前预检端口/容器占用,被占用即中止,绝不复用或杀掉既有进程(拒绝路径有 `qa/lifecycle-test.sh` 回归保障)。
+- 每次运行使用 `mktemp -d /tmp/opencode/fund-lab-qa-*` 专属临时目录,日志、截图、env 互不覆盖,失败现场完整保留。
+- Postgres 密码随机生成,仅写入本次临时目录的 `pg-env`(0600),不回显、不进仓库。
+- API 环境变量:`FUND_LAB_AUTH_TOKEN=qa-token`、`FUND_LAB_AKSHARE_PYTHON=<repo>/qa/stub/fund-lab-python`。
+- 退出时清理:杀掉自身启动的 API/Vite PID;仅删除本次 `docker run` 创建的容器(按容器 ID 与名称双重归属校验,拒绝路径绝不触碰既有容器);删除临时 env 文件。日志与截图保留在临时目录中供排查。
+
+## 生命周期回归(qa/lifecycle-test.sh)
+
+```bash
+bash qa/lifecycle-test.sh
+```
+
+验证 `run.sh` 的拒绝路径不破坏既有资源:预置同名容器或占用端口时 `run.sh` 必须拒绝退出,且既有容器与端口占用进程原样保留。脚本自身也只管理它创建的资源。
+
+## 行情桩(stub)
+
+`qa/stub/fund-lab-python` 是一个假解释器:`MarketDataService` 以 `FUND_LAB_AKSHARE_PYTHON` 调用它并传 `akshare_collector.py --operation search|nav ...`,它按 collector JSON 契约向 stdout 输出 envelope:
+
+- `schema_version="fund-lab.akshare.v1"`、`operation`、`source="stub-synthetic"`、`source_revision`、`collected_at`
+- 每个载荷都带 `_synthetic:true`,来源标记为 stub(合成数据不做真实披露)
+
+## 浏览器场景(qa/driver/browser-test.js)
+
+- A 初始渲染:创建面板、名称/现金输入框、按钮文案。
+- B 校验:空名称、非法金额(`abc`、`-5.00`)被拒绝且不发出任何 POST。
+- C 创建成功:双击仅 1 次 POST;`Idempotency-Key` 为 32 位十六进制;请求体金额为 decimal 字符串(`initialCash="12345.67"`、`initialUnitNav="1.00000000"`,不经 JS float);`isSynthetic=false`;档案展示服务端返回值,份额诚实显示 0。
+- D 重新读取:恰好 1 次 GET,金额持久化复核。
+- E token 失效:换 token 立即清空档案;拦截并延迟的旧 GET 响应落地后被忽略(stale 防护)。
+- F 幂等重试:首 POST 被掐断显示错误横幅,重试复用同一 `Idempotency-Key` 成功。
+- G 全程无浏览器 console/page 错误。
+
+## 真实行情模式
+
+QA 默认走合成桩。需要真实 AKShare 时,不运行 `run.sh`,而是自行启动 Postgres 并把 API 的 `FUND_LAB_AKSHARE_PYTHON` 指向装有 akshare 的解释器,例如本机已验证可用:
+
+```bash
+PYTHONPATH=/tmp/opencode/fund-lab-akshare-site /usr/bin/python3 -c "import akshare" # 1.18.96
+```
+
+真实模式输出的 envelope 不含 `_synthetic`,来源为真实数据源;前端“数据来源”行相应显示“真实建档”。
+
+## 不变量
+
+- 金额永远以 decimal 字符串提交/展示;浏览器端不经过任何 float 运算(见 `qa/driver/browser-test.js` 场景 C3 对请求体的断言)。
+- 网络失败后的重试复用同一 `Idempotency-Key`;输入变更或创建成功后才换新 key。
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js
new file mode 100644
index 0000000..7a45441
--- /dev/null
+++ b/qa/driver/browser-test.js
@@ -0,0 +1,280 @@
+const { chromium } = require("playwright-core");
+
+const BASE = process.env.QA_BASE_URL || "http://127.0.0.1:5175/";
+const SHOTS = process.env.QA_SHOTS || "/tmp/opencode/fund-lab-qa-screens";
+const EXE = "/home/somhairle/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome";
+
+const results = [];
+const consoleErrors = [];
+const fundPosts = [];
+let fundPostResponses = 0;
+let fundGets = 0;
+let createdFund = null;
+let createdFundResponse = null;
+
+function check(name, ok, detail) {
+ results.push({ name, ok, detail: detail || "" });
+ console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " -- " + detail : ""}`);
+}
+
+async function expectText(page, selector, expected, name, timeout = 10000) {
+ try {
+ await page.waitForSelector(selector, { timeout });
+ await page.waitForFunction(
+ ({ sel, want }) => {
+ const el = document.querySelector(sel);
+ return el !== null && (el.textContent || "").trim() === want;
+ },
+ { sel: selector, want: expected },
+ { timeout }
+ );
+ check(name, true);
+ } catch (e) {
+ let got = "";
+ try {
+ got = ((await page.textContent(selector)) || "").trim();
+ } catch {}
+ check(name, false, `got="${got}" want="${expected}"`);
+ }
+}
+
+async function summaryValue(page, label, expected, name) {
+ try {
+ await page.waitForFunction(
+ ({ want }) => {
+ const rows = Array.from(document.querySelectorAll(".fund-detail-row"));
+ return rows.some((r) => {
+ const l = r.querySelector(".fund-detail-label");
+ const v = r.querySelector(".fund-detail-value");
+ return l && v && (l.textContent || "").trim() === want.label && (v.textContent || "").trim() === want.value;
+ });
+ },
+ { want: { label, value: expected } },
+ { timeout: 10000 }
+ );
+ check(name, true);
+ } catch (e) {
+ check(name, false, `summary row ${label} != "${expected}"`);
+ }
+}
+
+async function summaryLabelExists(page, label, name) {
+ try {
+ await page.waitForFunction(
+ (want) => {
+ const rows = Array.from(document.querySelectorAll(".fund-detail-row"));
+ return rows.some((r) => (r.querySelector(".fund-detail-label")?.textContent || "").trim() === want);
+ },
+ label,
+ { timeout: 10000 }
+ );
+ check(name, true);
+ } catch (e) {
+ check(name, false, `summary row ${label} missing`);
+ }
+}
+
+(async () => {
+ const browser = await chromium.launch({
+ executablePath: EXE,
+ headless: true,
+ args: ["--no-sandbox", "--disable-dev-shm-usage"],
+ });
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
+ page.on("pageerror", (e) => consoleErrors.push("pageerror: " + e.message));
+ page.on("response", async (r) => {
+ if (r.url().includes("/api/funds") && r.request().method() === "POST" && r.status() < 400) {
+ fundPostResponses++;
+ try {
+ if (!createdFundResponse) createdFundResponse = await r.json();
+ } catch {}
+ }
+ if (r.url().includes("/api/funds/") && r.request().method() === "GET" && r.status() < 400) fundGets++;
+ if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url())) {
+ consoleErrors.push("resource " + r.status() + ": " + r.url());
+ }
+ });
+ page.on("request", (r) => {
+ if (r.url().includes("/api/funds") && r.method() === "POST") {
+ fundPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" });
+ }
+ });
+ page.on("console", (m) => {
+ if (m.type() !== "error") return;
+ const text = m.text();
+ if (/favicon/i.test(text)) return;
+ if (/401 \(Unauthorized\)/.test(text)) return;
+ if (/Failed to load resource/.test(text) && !text.includes("api/")) return;
+ consoleErrors.push("console: " + text);
+ });
+
+ try {
+ await page.goto(BASE, { waitUntil: "networkidle", timeout: 30000 });
+
+ await expectVisibleFundPanel(page);
+ await validationScenario(page);
+ await createScenario(page);
+ await rereadScenario(page);
+ await staleTokenScenario(page);
+ await idempotentRetryScenario(page);
+ } finally {
+ check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | "));
+ await browser.close();
+ }
+
+ const failed = results.filter((r) => !r.ok);
+ console.log(`\n==== ${results.length - failed.length}/${results.length} passed ====`);
+ process.exit(failed.length > 0 ? 1 : 0);
+})().catch((e) => {
+ console.error("QA driver crashed:", e);
+ process.exit(2);
+});
+
+async function expectVisibleFundPanel(page) {
+ await page.waitForSelector(".fund-panel", { timeout: 15000 });
+ await page.waitForSelector(".fund-name-input", { timeout: 10000 });
+ await page.waitForSelector(".fund-cash-input", { timeout: 10000 });
+ check("A1 初始渲染: 创建面板/名称/现金输入框", true);
+ const eyebrow = ((await page.textContent(".fund-panel .eyebrow")) || "").trim();
+ check("A2 面板 eyebrow 为 03 / CREATE", eyebrow === "03 / CREATE", eyebrow);
+ const btn = ((await page.textContent(".fund-create-action")) || "").trim();
+ check("A3 创建按钮初始为 创建基金", btn === "创建基金", btn);
+ const summaryCount = await page.locator(".fund-summary").count();
+ check("A4 初始无基金档案", summaryCount === 0, `count=${summaryCount}`);
+ await page.screenshot({ path: SHOTS + "/01-initial.png" });
+}
+
+async function validationScenario(page) {
+ await page.fill(".token-input", "qa-token");
+ await page.fill(".fund-name-input", "");
+ await page.fill(".fund-cash-input", "");
+ await page.click(".fund-create-action");
+ await expectText(page, ".error-banner", "请输入基金名称", "B1 空名称校验提示");
+
+ await page.fill(".fund-name-input", "稳健一号");
+ await page.fill(".fund-cash-input", "abc");
+ await page.click(".fund-create-action");
+ await expectText(
+ page,
+ ".error-banner",
+ "初始现金必须是合法金额,例如 10000.00",
+ "B2 非法金额校验提示"
+ );
+
+ await page.fill(".fund-cash-input", "-5.00");
+ await page.click(".fund-create-action");
+ const negativeBlocked = await page
+ .waitForSelector(".error-banner", { timeout: 5000 })
+ .then(() => true)
+ .catch(() => false);
+ check("B3 负数金额被拒绝", negativeBlocked, "banner presence");
+ check("B4 校验阶段无 POST /api/funds", fundPosts.length === 0, `posts=${fundPosts.length}`);
+ await page.screenshot({ path: SHOTS + "/02-validation.png" });
+}
+
+async function createScenario(page) {
+ await page.fill(".fund-name-input", "稳健一号");
+ await page.fill(".fund-cash-input", "12345.67");
+ await page.evaluate(() => {
+ const btn = document.querySelector(".fund-create-action");
+ btn.click();
+ btn.click();
+ });
+ await page.waitForSelector(".fund-summary", { timeout: 15000 });
+
+ check("C1 创建阶段恰好 1 次 POST(双击防护)", fundPosts.length === 1, `posts=${fundPosts.length}`);
+ const post = fundPosts[0] || { key: "", body: "" };
+ check("C2 幂等键为 32 位十六进制", /^[0-9a-f]{32}$/.test(post.key), post.key);
+ let body = {};
+ try {
+ body = JSON.parse(post.body);
+ } catch {}
+ check(
+ "C3 请求体金额为 decimal 字符串",
+ body.initialCash === "12345.67" && body.initialUnitNav === "1.00000000",
+ post.body.slice(0, 200)
+ );
+ check("C4 请求体 isSynthetic=false", body.isSynthetic === false, String(body.isSynthetic));
+ check("C5 请求体名称为 稳健一号", body.name === "稳健一号", String(body.name));
+
+ await summaryLabelExists(page, "基金ID", "C6 档案含基金ID");
+ await summaryValue(page, "初始现金", "12345.67", "C7 初始现金 12345.67");
+ await summaryValue(page, "初始单位净值", "1.00000000", "C8 初始单位净值 1.00000000");
+ await summaryValue(page, "份额", "0(新基金尚未买入持仓)", "C9 份额诚实显示 0");
+ await summaryValue(page, "数据来源", "真实建档", "C10 数据来源为 真实建档");
+
+ createdFund = { id: body.id || "", name: "稳健一号" };
+ try {
+ const idRow = await page.locator(".fund-detail-row", { hasText: "基金ID" }).first().textContent();
+ const m = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(idRow || "");
+ if (m) createdFund.id = m[0];
+ } catch {}
+ check(
+ "C11 已捕获完整档案响应载荷",
+ !!(createdFundResponse && createdFundResponse.id && createdFundResponse.name && createdFundResponse.availableCash),
+ JSON.stringify(createdFundResponse || {}).slice(0, 160)
+ );
+ await page.screenshot({ path: SHOTS + "/03-created.png" });
+}
+
+async function rereadScenario(page) {
+ await page.click(".fund-read-action");
+ await expectText(page, ".fund-read-action", "重新读取", "D1 重新读取完成按钮恢复", 15000);
+ check("D2 重新读取恰好 1 次 GET", fundGets === 1, `gets=${fundGets}`);
+ await summaryValue(page, "可用现金", "12345.67", "D3 重读后可用现金 12345.67");
+ await page.screenshot({ path: SHOTS + "/04-reread.png" });
+}
+
+async function staleTokenScenario(page) {
+ check(
+ "E0 延迟响应使用完整档案载荷",
+ !!(createdFundResponse && createdFundResponse.id && createdFundResponse.name && createdFundResponse.availableCash),
+ JSON.stringify(createdFundResponse || {}).slice(0, 160)
+ );
+ await page.route("**/api/funds/*", async (route) => {
+ await new Promise((r) => setTimeout(r, 1200));
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(createdFundResponse || createdFund || {}),
+ });
+ });
+ await page.click(".fund-read-action");
+ await page.fill(".token-input", "qa-token-rotated");
+ await page.waitForSelector(".fund-summary", { state: "detached", timeout: 10000 });
+ check("E1 换 token 立即清空基金档案", true);
+ await page.waitForTimeout(1800);
+ const summaryAfter = await page.locator(".fund-summary").count();
+ check("E2 延迟响应落地后档案仍被清除(stale 忽略)", summaryAfter === 0, `count=${summaryAfter}`);
+ await page.unroute("**/api/funds/*");
+ await page.fill(".token-input", "qa-token");
+ await page.screenshot({ path: SHOTS + "/05-stale-token.png" });
+}
+
+async function idempotentRetryScenario(page) {
+ let aborted = false;
+ await page.route("**/api/funds", async (route) => {
+ if (!aborted) {
+ aborted = true;
+ await route.abort("failed");
+ } else {
+ await route.continue();
+ }
+ });
+
+ await page.fill(".fund-name-input", "稳健二号");
+ await page.fill(".fund-cash-input", "20000.00");
+ await page.click(".fund-create-action");
+ await page.waitForSelector(".error-banner", { timeout: 15000 });
+ const failedKey = fundPosts[fundPosts.length - 1]?.key || "";
+ check("F1 首次 POST 失败显示错误横幅", true);
+
+ await page.click(".fund-create-action");
+ await page.waitForSelector(".fund-summary", { timeout: 15000 });
+ const retryKey = fundPosts[fundPosts.length - 1]?.key || "";
+ check("F2 重试复用同一 Idempotency-Key", failedKey !== "" && failedKey === retryKey, `${failedKey} vs ${retryKey}`);
+ check("F3 失败后恰好 2 次 POST", fundPosts.length === 3, `posts=${fundPosts.length}`);
+ await summaryValue(page, "名称", "稳健二号", "F4 重试成功档案为 稳健二号");
+ await page.unroute("**/api/funds");
+ await page.screenshot({ path: SHOTS + "/06-idempotent-retry.png" });
+}
diff --git a/qa/driver/package-lock.json b/qa/driver/package-lock.json
new file mode 100644
index 0000000..9592f5d
--- /dev/null
+++ b/qa/driver/package-lock.json
@@ -0,0 +1,28 @@
+{
+ "name": "fund-lab-qa-driver",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "fund-lab-qa-driver",
+ "version": "1.0.0",
+ "license": "UNLICENSED",
+ "dependencies": {
+ "playwright-core": "^1.63.0"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/qa/driver/package.json b/qa/driver/package.json
new file mode 100644
index 0000000..74ac02a
--- /dev/null
+++ b/qa/driver/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "fund-lab-qa-driver",
+ "version": "1.0.0",
+ "private": true,
+ "license": "UNLICENSED",
+ "dependencies": {
+ "playwright-core": "^1.63.0"
+ }
+}
diff --git a/qa/lifecycle-test.sh b/qa/lifecycle-test.sh
new file mode 100755
index 0000000..ace6a08
--- /dev/null
+++ b/qa/lifecycle-test.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# Lifecycle regression for qa/run.sh refusal paths.
+# Verifies that run.sh refuses to start when a container name or port is
+# already taken, and that the refusal leaves the pre-existing resources
+# untouched (no container deletion, no killing of the port holder).
+# The script only manages resources it created itself.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PG_CONTAINER=fund-lab-qa-pg-3c1
+PG_IMAGE=postgres:16-alpine
+API_PORT=5095
+TMP="$(mktemp -d /tmp/opencode/fund-lab-qa-lifecycle-XXXXXX)"
+HOLDER_PID=""
+
+cleanup() {
+ local status=$?
+ trap - EXIT INT TERM
+ [ -n "$HOLDER_PID" ] && kill "$HOLDER_PID" 2>/dev/null || true
+ [ -n "$HOLDER_PID" ] && wait "$HOLDER_PID" 2>/dev/null || true
+ if [ -n "$DUMMY_ID" ]; then
+ owned="$(docker ps -a --filter "id=$DUMMY_ID" --format '{{.Names}}' 2>/dev/null || true)"
+ [ "$owned" = "$PG_CONTAINER" ] && docker rm -f "$DUMMY_ID" >/dev/null 2>&1 || true
+ fi
+ rm -rf "$TMP"
+ exit "$status"
+}
+DUMMY_ID=""
+trap cleanup EXIT INT TERM
+
+log() { printf '[lifecycle] %s\n' "$*"; }
+fail() { printf '[lifecycle] FAIL: %s\n' "$*" >&2; exit 1; }
+pass() { printf '[lifecycle] PASS: %s\n' "$*"; }
+
+command -v docker >/dev/null || fail "docker is required"
+docker info >/dev/null 2>&1 || fail "docker daemon is not reachable"
+docker image inspect "$PG_IMAGE" >/dev/null 2>&1 || fail "$PG_IMAGE is not available locally"
+docker ps -a --format '{{.Names}}' | grep -qx "$PG_CONTAINER" && fail "container $PG_CONTAINER already exists; refusing to proceed"
+
+log "scenario 1: pre-existing container name is refused and preserved"
+DUMMY_ID="$(docker run -d --name "$PG_CONTAINER" "$PG_IMAGE" sleep 120)"
+if bash "$ROOT/qa/run.sh" >"$TMP/refuse-container.log" 2>&1; then
+ fail "run.sh should refuse when $PG_CONTAINER already exists"
+fi
+grep -q "already exists" "$TMP/refuse-container.log" || fail "refusal reason missing from log"
+[ "$(docker ps -a --filter "id=$DUMMY_ID" --format '{{.Names}}' 2>/dev/null)" = "$PG_CONTAINER" ] \
+ || fail "pre-existing container was removed or modified by the refused run"
+pass "refusal preserved the pre-existing container"
+docker rm -f "$DUMMY_ID" >/dev/null
+DUMMY_ID=""
+
+log "scenario 2: occupied API port is refused and the holder is untouched"
+python3 -m http.server "$API_PORT" --bind 127.0.0.1 >/dev/null 2>&1 &
+HOLDER_PID=$!
+sleep 0.5
+kill -0 "$HOLDER_PID" 2>/dev/null || fail "port holder failed to start"
+if bash "$ROOT/qa/run.sh" >"$TMP/refuse-port.log" 2>&1; then
+ fail "run.sh should refuse when port $API_PORT is occupied"
+fi
+grep -q "port $API_PORT is already in use" "$TMP/refuse-port.log" || fail "refusal reason missing from log"
+kill -0 "$HOLDER_PID" 2>/dev/null || fail "port holder was killed by the refused run"
+pass "refusal preserved the port holder"
+
+log "all lifecycle scenarios passed"
diff --git a/qa/run.sh b/qa/run.sh
new file mode 100755
index 0000000..bdfba3f
--- /dev/null
+++ b/qa/run.sh
@@ -0,0 +1,133 @@
+#!/usr/bin/env bash
+# End-to-end browser QA for fund-lab (work order 3c-1 scope and regressions).
+# Starts: disposable Postgres (port 55434), API (5095), Vite dev server (5175),
+# then drives the UI with headless Chromium via qa/driver/browser-test.js.
+# All ports and the container name are non-default and cleaned up on exit.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+QA_DIR="$ROOT/qa"
+API_PORT=5095
+WEB_PORT=5175
+PG_PORT=55434
+PG_CONTAINER=fund-lab-qa-pg-3c1
+PG_IMAGE=postgres:16-alpine
+PG_DB=fund_lab_qa
+PG_USER=fund_qa
+AUTH_TOKEN=qa-token
+TMP_DIR="$(mktemp -d /tmp/opencode/fund-lab-qa-XXXXXX)"
+ENV_FILE="$TMP_DIR/pg-env"
+API_LOG="$TMP_DIR/api.log"
+WEB_LOG="$TMP_DIR/web.log"
+API_PID=""
+WEB_PID=""
+CREATED_CONTAINER_ID=""
+
+log() { printf '[qa] %s\n' "$*"; }
+die() { printf '[qa] FATAL: %s\n' "$*" >&2; exit 1; }
+
+cleanup() {
+ local status=$?
+ trap - EXIT INT TERM
+ [ -n "$WEB_PID" ] && kill "$WEB_PID" 2>/dev/null || true
+ [ -n "$API_PID" ] && kill "$API_PID" 2>/dev/null || true
+ [ -n "$WEB_PID" ] && wait "$WEB_PID" 2>/dev/null || true
+ [ -n "$API_PID" ] && wait "$API_PID" 2>/dev/null || true
+ if [ -n "$CREATED_CONTAINER_ID" ]; then
+ owned="$(docker ps -a --filter "id=$CREATED_CONTAINER_ID" --format '{{.Names}}' 2>/dev/null || true)"
+ [ "$owned" = "$PG_CONTAINER" ] && docker rm -f "$CREATED_CONTAINER_ID" >/dev/null 2>&1 || true
+ fi
+ rm -f "$ENV_FILE"
+ log "cleanup done (exit=$status)"
+ log "artifacts preserved in $TMP_DIR"
+ exit "$status"
+}
+trap cleanup EXIT INT TERM
+
+port_in_use() {
+ (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && { exec 3>&- 3<&-; return 0; } || return 1
+}
+
+command -v docker >/dev/null || die "docker is required"
+command -v openssl >/dev/null || die "openssl is required"
+docker info >/dev/null 2>&1 || die "docker daemon is not reachable"
+docker image inspect "$PG_IMAGE" >/dev/null 2>&1 || die "$PG_IMAGE is not available locally; pull it first"
+docker ps -a --format '{{.Names}}' | grep -qx "$PG_CONTAINER" && die "container $PG_CONTAINER already exists; refusing to touch it"
+for port in "$API_PORT" "$WEB_PORT" "$PG_PORT"; do
+ port_in_use "$port" && die "port $port is already in use; refusing to continue"
+done
+[ -x "$QA_DIR/stub/fund-lab-python" ] || chmod +x "$QA_DIR/stub/fund-lab-python"
+export PATH="$ROOT/.tools/node-v22.23.2/bin:$PATH"
+command -v node >/dev/null || die "node not found in .tools"
+command -v dotnet >/dev/null || die "dotnet not found on PATH"
+
+mkdir -p "$TMP_DIR"
+umask 077
+PG_PASSWORD="$(openssl rand -hex 24)"
+printf 'PG_PASSWORD=%s\n' "$PG_PASSWORD" > "$ENV_FILE"
+
+log "starting $PG_IMAGE as $PG_CONTAINER on 127.0.0.1:$PG_PORT"
+CREATED_CONTAINER_ID="$(docker run -d --name "$PG_CONTAINER" \
+ -e POSTGRES_DB="$PG_DB" -e POSTGRES_USER="$PG_USER" -e POSTGRES_PASSWORD="$PG_PASSWORD" \
+ -p "127.0.0.1:$PG_PORT:5432" "$PG_IMAGE")"
+
+pg_ready=0
+for _ in $(seq 1 60); do
+ if docker exec "$PG_CONTAINER" pg_isready -U "$PG_USER" -d "$PG_DB" >/dev/null 2>&1; then
+ pg_ready=1
+ break
+ fi
+ sleep 0.5
+done
+if [ "$pg_ready" != 1 ]; then
+ docker ps -a --format '{{.Names}} {{.Status}}' | grep "$PG_CONTAINER" >&2 || true
+ docker logs --tail 20 "$PG_CONTAINER" >&2 || true
+ die "postgres did not become ready"
+fi
+
+log "building API"
+dotnet build "$ROOT/src/FundLab.Api/FundLab.Api.fsproj" -v q --nologo >/dev/null
+
+log "starting API on 127.0.0.1:$API_PORT (stub interpreter, _synthetic payloads)"
+FUND_LAB_DATABASE_URL="Host=127.0.0.1;Port=$PG_PORT;Database=$PG_DB;Username=$PG_USER;Password=$PG_PASSWORD;Pooling=false" \
+FUND_LAB_AUTH_TOKEN="$AUTH_TOKEN" \
+FUND_LAB_AKSHARE_PYTHON="$QA_DIR/stub/fund-lab-python" \
+ASPNETCORE_URLS="http://127.0.0.1:$API_PORT" \
+ dotnet "$ROOT/src/FundLab.Api/bin/Debug/net8.0/FundLab.Api.dll" > "$API_LOG" 2>&1 &
+API_PID=$!
+
+api_up=0
+for _ in $(seq 1 60); do
+ code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$API_PORT/api/instruments?query=x" || true)"
+ [ "$code" != "000" ] && { api_up=1; break; }
+ sleep 0.5
+done
+[ "$api_up" = 1 ] || { cat "$API_LOG" >&2; die "API did not become ready"; }
+
+log "compiling F# to JS (fable)"
+( cd "$ROOT/src/FundLab.Web" && dotnet fable FundLab.Web.fsproj --outDir dist --noCache ) > "$TMP_DIR/fable.log" 2>&1
+
+log "starting Vite dev server on 127.0.0.1:$WEB_PORT"
+( cd "$ROOT/src/FundLab.Web" && FUND_LAB_API_TARGET="http://127.0.0.1:$API_PORT" exec node node_modules/vite/bin/vite.js --host 127.0.0.1 --port "$WEB_PORT" --strictPort ) \
+ > "$WEB_LOG" 2>&1 &
+WEB_PID=$!
+
+web_up=0
+for _ in $(seq 1 60); do
+ code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$WEB_PORT/" || true)"
+ [ "$code" = "200" ] && { web_up=1; break; }
+ sleep 0.5
+done
+[ "$web_up" = 1 ] || { cat "$WEB_LOG" >&2; die "vite dev server did not become ready"; }
+
+if [ ! -d "$QA_DIR/driver/node_modules" ]; then
+ log "installing driver dependencies"
+ ( cd "$QA_DIR/driver" && npm install --no-audit --no-fund --loglevel=error >/dev/null )
+fi
+
+log "running browser scenarios"
+mkdir -p "$TMP_DIR/screens"
+QA_BASE_URL="http://127.0.0.1:$WEB_PORT/" \
+QA_SHOTS="$TMP_DIR/screens" \
+QA_TOKEN="$AUTH_TOKEN" \
+ node "$QA_DIR/driver/browser-test.js" 2>&1 | tee "$TMP_DIR/driver.log"
diff --git a/qa/stub/fund-lab-python b/qa/stub/fund-lab-python
new file mode 100755
index 0000000..9cdfa6a
--- /dev/null
+++ b/qa/stub/fund-lab-python
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# QA stub interpreter: emulates the akshare_collector.py JSON contract
+# (source=stub-synthetic, _synthetic=true on every payload).
+# Invoked by MarketDataService as: fund-lab-python <script> --operation search --query X | --operation nav --code X
+set -euo pipefail
+
+OP=""
+QUERY=""
+CODE=""
+args=("$@")
+for ((i=1; i<${#args[@]}; i++)); do
+ case "${args[$i]}" in
+ --operation) OP="${args[$((i+1))]:-}"; i=$((i+1)) ;;
+ --query) QUERY="${args[$((i+1))]:-}"; i=$((i+1)) ;;
+ --code) CODE="${args[$((i+1))]:-}"; i=$((i+1)) ;;
+ esac
+done
+
+TS="$(date -u +%Y-%m-%dT%H:%M:%S+00:00)"
+
+case "$OP" in
+ search)
+ printf '{"schema_version":"fund-lab.akshare.v1","operation":"search","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instruments":[{"code":"000001","name":"桩测试基金一号","fund_type":"混合型"},{"code":"110011","name":"桩测试基金二号","fund_type":null}]}\n' "$TS"
+ ;;
+ nav)
+ printf '{"schema_version":"fund-lab.akshare.v1","operation":"nav","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"observations":[{"nav_date":"2026-09-15","published_at":null,"nav":"1.1000","accumulated_nav":null,"daily_return":null},{"nav_date":"2026-09-16","published_at":null,"nav":"1.1205","accumulated_nav":null,"daily_return":"1.86"},{"nav_date":"2026-09-17","published_at":null,"nav":"1.1180","accumulated_nav":null,"daily_return":"-0.22"},{"nav_date":"2026-09-18","published_at":null,"nav":"1.1500","accumulated_nav":null,"daily_return":"2.86"},{"nav_date":"2026-09-21","published_at":null,"nav":"1.1902","accumulated_nav":null,"daily_return":"3.49"}]}\n' "$TS" "$CODE"
+ ;;
+ *)
+ echo "unknown operation: $OP" >&2
+ exit 2
+ ;;
+esac
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs
index 0ee4d56..43c438f 100644
--- a/src/FundLab.Web/App.fs
+++ b/src/FundLab.Web/App.fs
@@ -99,6 +99,34 @@ type RawNavResponse =
observations: RawObservation array
}
+type CreateFundPayload =
+ {
+ idempotencyKey: string
+ name: string
+ initialCash: string
+ initialUnitNav: string
+ isSynthetic: bool
+ }
+
+type FundSummary =
+ {
+ id: string
+ name: string
+ currency: string
+ initialCash: string
+ initialUnitNav: string
+ isSynthetic: bool
+ availableCash: string
+ status: string
+ }
+
+type CreateAttempt =
+ {
+ idempotencyKey: string
+ name: string
+ cash: string
+ }
+
module Api =
[<Import("searchInstruments", "./src/api.js")>]
let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative
@@ -109,6 +137,12 @@ module Api =
[<Import("refreshNav", "./src/api.js")>]
let refreshNav (token: string) (code: string) : JS.Promise<RawNavResponse> = jsNative
+ [<Import("createFund", "./src/api.js")>]
+ let createFund (token: string) (payload: CreateFundPayload) : JS.Promise<FundSummary> = jsNative
+
+ [<Import("getFund", "./src/api.js")>]
+ let getFund (token: string) (fundId: string) : JS.Promise<FundSummary> = jsNative
+
let decodeOptionalText (raw: obj) : string option =
if isNull raw then
None
@@ -151,6 +185,12 @@ type Model =
observations: NavObservation list
searchInFlight: bool
navInFlight: bool
+ fundName: string
+ fundCash: string
+ fundSeq: int
+ fundInFlight: bool
+ createdFund: FundSummary option
+ lastCreateAttempt: CreateAttempt option
error: string option
}
@@ -165,6 +205,26 @@ type Msg =
| RefreshNavRequested
| NavCompleted of requestId: int * observations: NavObservation list
| NavFailed of requestId: int * message: string
+ | FundNameChanged of string
+ | FundCashChanged of string
+ | FundCreateRequested
+ | FundCreateCompleted of requestId: int * fund: FundSummary
+ | FundCreateFailed of requestId: int * message: string
+ | FundReadRequested
+ | FundReadCompleted of requestId: int * fund: FundSummary
+ | FundReadFailed of requestId: int * message: string
+
+let defaultInitialUnitNav = "1.00000000"
+
+let private cashPattern =
+ Text.RegularExpressions.Regex("^[0-9]{1,18}(\.[0-9]{1,2})?$", Text.RegularExpressions.RegexOptions.Compiled)
+
+let isValidCashText (text: string) = cashPattern.IsMatch text
+
+let resolveCreateKey (lastAttempt: CreateAttempt option) (name: string) (cash: string) =
+ match lastAttempt with
+ | Some attempt when attempt.name = name && attempt.cash = cash -> attempt.idempotencyKey
+ | _ -> Guid.NewGuid().ToString("N")
let init () =
{
@@ -177,6 +237,12 @@ let init () =
observations = []
searchInFlight = false
navInFlight = false
+ fundName = ""
+ fundCash = ""
+ fundSeq = 0
+ fundInFlight = false
+ createdFund = None
+ lastCreateAttempt = None
error = None
}
@@ -207,6 +273,20 @@ let private refreshCommand token code requestId =
(fun raw -> NavCompleted(requestId, Api.decodeNav raw))
(fun error -> NavFailed(requestId, errorText error))
+let private createFundCommand token payload requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.createFund token payload)
+ ()
+ (fun fund -> FundCreateCompleted(requestId, fund))
+ (fun error -> FundCreateFailed(requestId, errorText error))
+
+let private readFundCommand token fundId requestId =
+ Cmd.OfPromise.either
+ (fun () -> Api.getFund token fundId)
+ ()
+ (fun fund -> FundReadCompleted(requestId, fund))
+ (fun error -> FundReadFailed(requestId, errorText error))
+
let update message model =
match message with
| TokenChanged token ->
@@ -220,6 +300,12 @@ let update message model =
observations = []
searchInFlight = false
navInFlight = false
+ fundName = ""
+ fundCash = ""
+ fundSeq = model.fundSeq + 1
+ fundInFlight = false
+ createdFund = None
+ lastCreateAttempt = None
error = None
},
Cmd.none
@@ -322,6 +408,86 @@ let update message model =
{ model with navInFlight = false; error = Some message }, Cmd.none
else
model, Cmd.none
+ | FundNameChanged value ->
+ { model with fundName = value; error = None; lastCreateAttempt = None }, Cmd.none
+ | FundCashChanged value ->
+ { model with fundCash = value; error = None; lastCreateAttempt = None }, Cmd.none
+ | FundCreateRequested ->
+ let name = model.fundName.Trim()
+ let cash = model.fundCash.Trim()
+
+ if String.IsNullOrWhiteSpace model.token then
+ { model with error = Some "请输入 API token" }, Cmd.none
+ elif name = "" then
+ { model with error = Some "请输入基金名称" }, Cmd.none
+ elif not (isValidCashText cash) then
+ { model with error = Some "初始现金必须是合法金额,例如 10000.00" }, Cmd.none
+ elif model.fundInFlight then
+ model, Cmd.none
+ else
+ let requestId = model.fundSeq + 1
+ let idempotencyKey = resolveCreateKey model.lastCreateAttempt name cash
+
+ {
+ model with
+ fundName = name
+ fundCash = cash
+ fundSeq = requestId
+ fundInFlight = true
+ lastCreateAttempt = Some { idempotencyKey = idempotencyKey; name = name; cash = cash }
+ error = None
+ },
+ createFundCommand
+ model.token
+ {
+ idempotencyKey = idempotencyKey
+ name = name
+ initialCash = cash
+ initialUnitNav = defaultInitialUnitNav
+ isSynthetic = false
+ }
+ requestId
+ | FundCreateCompleted (requestId, fund) ->
+ if requestId = model.fundSeq then
+ {
+ model with
+ createdFund = Some fund
+ fundInFlight = false
+ lastCreateAttempt = None
+ error = None
+ },
+ Cmd.none
+ else
+ model, Cmd.none
+ | FundCreateFailed (requestId, message) ->
+ if requestId = model.fundSeq then
+ { model with fundInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
+ | FundReadRequested ->
+ match model.createdFund with
+ | Some fund when not (String.IsNullOrWhiteSpace model.token) ->
+ if model.fundInFlight then
+ model, Cmd.none
+ else
+ let requestId = model.fundSeq + 1
+
+ { model with fundSeq = requestId; fundInFlight = true; error = None },
+ readFundCommand model.token fund.id requestId
+ | Some _ ->
+ { model with error = Some "请输入 API token" }, Cmd.none
+ | None ->
+ { model with error = Some "请先创建一个基金" }, Cmd.none
+ | FundReadCompleted (requestId, fund) ->
+ if requestId = model.fundSeq then
+ { model with createdFund = Some fund; fundInFlight = false; error = None }, Cmd.none
+ else
+ model, Cmd.none
+ | FundReadFailed (requestId, message) ->
+ if requestId = model.fundSeq then
+ { model with fundInFlight = false; error = Some message }, Cmd.none
+ else
+ model, Cmd.none
let private navText (text: string) =
match Decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture) with
@@ -556,6 +722,103 @@ let private navPanel model dispatch =
]
]
+let private fundSummaryRow (label: string) (value: string) =
+ Html.div [
+ prop.className "fund-detail-row"
+ prop.children [
+ Html.span [ prop.className "fund-detail-label"; prop.text label ]
+ Html.span [ prop.className "fund-detail-value"; prop.text value ]
+ ]
+ ]
+
+let private fundPanel model dispatch =
+ Html.section [
+ prop.className "panel fund-panel"
+ prop.children [
+ Html.div [
+ prop.className "section-heading"
+ prop.children [
+ Html.div [
+ Html.p [ prop.className "eyebrow"; prop.text "03 / CREATE" ]
+ Html.h2 "创建一笔真实基金"
+ ]
+ Html.span [ prop.className "section-note"; prop.text "PostgreSQL · idempotent" ]
+ ]
+ ]
+ Html.div [
+ prop.className "fund-form-row"
+ prop.children [
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "基金名称"
+ Html.input [
+ prop.className "text-input fund-name-input"
+ prop.placeholder "例如 稳健一号"
+ prop.value model.fundName
+ prop.onChange (fun value -> dispatch (FundNameChanged value))
+ ]
+ ]
+ ]
+ Html.label [
+ prop.className "field-label"
+ prop.children [
+ Html.span "初始现金(元)"
+ Html.input [
+ prop.className "text-input fund-cash-input"
+ prop.placeholder "例如 10000.00"
+ prop.value model.fundCash
+ prop.onChange (fun value -> dispatch (FundCashChanged value))
+ ]
+ ]
+ ]
+ Html.button [
+ prop.className "primary-action fund-create-action"
+ prop.disabled model.fundInFlight
+ prop.onClick (fun _ -> dispatch FundCreateRequested)
+ prop.text ((if model.fundInFlight then "创建中..." else "创建基金"): string)
+ ]
+ ]
+ ]
+ Html.p [
+ prop.className "hint"
+ prop.text "初始单位净值固定为 1.00000000;金额以 decimal 字符串提交,不经过浮点运算。"
+ ]
+ match model.createdFund with
+ | Some fund ->
+ Html.div [
+ prop.className "fund-summary"
+ prop.children [
+ fundSummaryRow "基金ID" fund.id
+ fundSummaryRow "名称" fund.name
+ fundSummaryRow "币种" fund.currency
+ fundSummaryRow "初始现金" fund.initialCash
+ fundSummaryRow "可用现金" fund.availableCash
+ fundSummaryRow "初始单位净值" fund.initialUnitNav
+ fundSummaryRow "份额" "0(新基金尚未买入持仓)"
+ fundSummaryRow "状态" fund.status
+ fundSummaryRow "数据来源" (if fund.isSynthetic then "合成(测试标记)" else "真实建档")
+ Html.div [
+ prop.className "panel-actions"
+ prop.children [
+ Html.button [
+ prop.className "secondary-action fund-read-action"
+ prop.disabled model.fundInFlight
+ prop.onClick (fun _ -> dispatch FundReadRequested)
+ prop.text ((if model.fundInFlight then "读取中..." else "重新读取"): string)
+ ]
+ ]
+ ]
+ ]
+ ]
+ | None ->
+ Html.p [
+ prop.className "hint"
+ prop.text "创建成功后,这里会展示服务端返回的基金档案,可通过重新读取复核持久化结果。"
+ ]
+ ]
+ ]
+
let view model dispatch =
Html.main [
prop.className "app-shell"
@@ -570,7 +833,7 @@ let view model dispatch =
Html.span "FUND LAB"
]
]
- Html.span [ prop.className "topbar-status"; prop.text "MARKET DATA / 3B" ]
+ Html.span [ prop.className "topbar-status"; prop.text "LEDGER / 3C-1" ]
]
]
Html.div [
@@ -610,7 +873,8 @@ let view model dispatch =
| None -> Html.none
searchPanel model dispatch
navPanel model dispatch
- Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / MODE · READ-ONLY" ]
+ fundPanel model dispatch
+ Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ" ]
]
]
diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js
index 7d12d4d..0d3b6fd 100644
--- a/src/FundLab.Web/src/api.js
+++ b/src/FundLab.Web/src/api.js
@@ -36,3 +36,19 @@ export function getNav(token, code) {
export function refreshNav(token, code) {
return requestJson(`/api/instruments/${encodeURIComponent(code)}/nav/refresh`, token, { method: "POST" });
}
+
+export function createFund(token, payload) {
+ const body = `{"name":${JSON.stringify(payload.name)},"initialCash":${JSON.stringify(payload.initialCash)},"initialUnitNav":${JSON.stringify(payload.initialUnitNav)},"isSynthetic":${payload.isSynthetic ? "true" : "false"}}`;
+ return requestJson("/api/funds", token, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Idempotency-Key": payload.idempotencyKey
+ },
+ body
+ });
+}
+
+export function getFund(token, fundId) {
+ return requestJson(`/api/funds/${encodeURIComponent(fundId)}`, token);
+}
diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css
index f895353..dcd8833 100644
--- a/src/FundLab.Web/src/styles.css
+++ b/src/FundLab.Web/src/styles.css
@@ -426,6 +426,59 @@ h2 {
margin-top: 24px;
}
+.fund-form-row {
+ display: flex;
+ gap: 12px;
+ align-items: flex-end;
+}
+
+.fund-form-row .field-label {
+ flex: 1;
+ min-width: 0;
+}
+
+.fund-form-row .primary-action {
+ flex: 0 0 auto;
+}
+
+.fund-summary {
+ display: grid;
+ margin-top: 20px;
+ padding: 8px 18px 18px;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ background: #fff;
+}
+
+.fund-detail-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 11px 0;
+ border-bottom: 1px dashed #e2e8f0;
+}
+
+.fund-detail-label {
+ color: #667085;
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.fund-detail-value {
+ overflow: hidden;
+ color: #172033;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 13px;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.fund-summary .panel-actions {
+ margin-top: 10px;
+}
+
.footer-note {
padding: 16px 0 34px;
font-family: "SFMono-Regular", Consolas, monospace;
@@ -466,10 +519,16 @@ h2 {
flex-direction: column;
}
- .search-row .primary-action {
+ .search-row .primary-action,
+ .fund-form-row .primary-action {
width: 100%;
}
+ .fund-form-row {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
.result-row {
grid-template-columns: 82px minmax(0, 1fr);
}
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs
index 9059cef..ed970bd 100644
--- a/tests/FundLab.Web.Tests/BoundaryTests.fs
+++ b/tests/FundLab.Web.Tests/BoundaryTests.fs
@@ -149,3 +149,180 @@ module BoundaryTests =
Assert.True(retryModel.searchInFlight)
Assert.Equal(None, retryModel.error)
Assert.Equal(2, retryModel.searchSeq)
+
+module FundBoundaryTests =
+
+ let sampleFund id : FundSummary =
+ {
+ id = id
+ name = "稳健一号"
+ currency = "CNY"
+ initialCash = "12345.67"
+ initialUnitNav = "1.00000000"
+ isSynthetic = false
+ availableCash = "12345.67"
+ status = "active"
+ }
+
+ [<Fact>]
+ let ``create completion stores server decimal strings untouched`` () =
+ let fund = sampleFund "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+ let model = { init () with token = "qa-token"; fundSeq = 1; fundInFlight = true }
+
+ let updated, _ = update (FundCreateCompleted(1, fund)) model
+
+ Assert.Equal(Some fund, updated.createdFund)
+ Assert.Equal("12345.67", updated.createdFund.Value.initialCash)
+ Assert.Equal("1.00000000", updated.createdFund.Value.initialUnitNav)
+ Assert.False(updated.fundInFlight)
+ Assert.Equal(None, updated.lastCreateAttempt)
+ Assert.Equal(None, updated.error)
+
+ [<Fact>]
+ let ``token change clears fund state and invalidates stale fund responses`` () =
+ let model =
+ { init () with
+ token = "old-token"
+ fundName = "旧基金"
+ fundCash = "100.00"
+ fundSeq = 4
+ fundInFlight = true
+ createdFund = Some(sampleFund "old-id")
+ lastCreateAttempt = Some { idempotencyKey = "key-1"; name = "旧基金"; cash = "100.00" } }
+
+ let cleared, _ = update (TokenChanged "new-token") model
+
+ Assert.Equal("", cleared.fundName)
+ Assert.Equal("", cleared.fundCash)
+ Assert.Equal(5, cleared.fundSeq)
+ Assert.False(cleared.fundInFlight)
+ Assert.Equal(None, cleared.createdFund)
+ Assert.Equal(None, cleared.lastCreateAttempt)
+
+ let staleCreated, _ = update (FundCreateCompleted(4, sampleFund "stale-id")) cleared
+
+ Assert.Equal(None, staleCreated.createdFund)
+
+ let staleCreateFailure, _ = update (FundCreateFailed(4, "过期创建失败")) cleared
+
+ Assert.Equal(None, staleCreateFailure.error)
+
+ let staleRead, _ = update (FundReadCompleted(4, sampleFund "stale-id")) cleared
+
+ Assert.Equal(None, staleRead.createdFund)
+
+ let staleReadFailure, _ = update (FundReadFailed(4, "过期读取失败")) cleared
+
+ Assert.Equal(None, staleReadFailure.error)
+
+ [<Fact>]
+ let ``create request rejects invalid inputs without starting a request`` () =
+ let invalidCash, _ =
+ update FundCreateRequested { init () with token = "qa-token"; fundName = "测试"; fundCash = "abc" }
+
+ Assert.Equal(Some "初始现金必须是合法金额,例如 10000.00", invalidCash.error)
+ Assert.Equal(0, invalidCash.fundSeq)
+ Assert.False(invalidCash.fundInFlight)
+
+ let noToken, _ =
+ update FundCreateRequested { init () with fundName = "测试"; fundCash = "100.00" }
+
+ Assert.Equal(Some "请输入 API token", noToken.error)
+ Assert.Equal(0, noToken.fundSeq)
+
+ let noName, _ =
+ update FundCreateRequested { init () with token = "qa-token"; fundCash = "100.00" }
+
+ Assert.Equal(Some "请输入基金名称", noName.error)
+ Assert.Equal(0, noName.fundSeq)
+
+ let negativeCash, _ =
+ update FundCreateRequested { init () with token = "qa-token"; fundName = "测试"; fundCash = "-5.00" }
+
+ Assert.Equal(Some "初始现金必须是合法金额,例如 10000.00", negativeCash.error)
+
+ let malformedCash, _ =
+ update FundCreateRequested { init () with token = "qa-token"; fundName = "测试"; fundCash = "1.2.3" }
+
+ Assert.Equal(Some "初始现金必须是合法金额,例如 10000.00", malformedCash.error)
+
+ [<Fact>]
+ let ``valid create request starts exactly one attempt with a fresh idempotency key`` () =
+ let model = { init () with token = "qa-token"; fundName = " 稳健一号 "; fundCash = " 12345.67 " }
+
+ let started, _ = update FundCreateRequested model
+
+ Assert.Equal(1, started.fundSeq)
+ Assert.True(started.fundInFlight)
+ Assert.Equal("稳健一号", started.fundName)
+ Assert.Equal("12345.67", started.fundCash)
+ Assert.Equal(None, started.error)
+
+ let attempt = started.lastCreateAttempt.Value
+
+ Assert.Equal("稳健一号", attempt.name)
+ Assert.Equal("12345.67", attempt.cash)
+ Assert.Equal(32, attempt.idempotencyKey.Length)
+
+ [<Fact>]
+ let ``second submit while a create is in flight is ignored`` () =
+ let model = { init () with token = "qa-token"; fundName = "稳健一号"; fundCash = "100.00" }
+
+ let started, _ = update FundCreateRequested model
+ let second, _ = update FundCreateRequested started
+
+ Assert.Equal(started.fundSeq, second.fundSeq)
+ Assert.Equal(started.lastCreateAttempt, second.lastCreateAttempt)
+ Assert.True(second.fundInFlight)
+
+ [<Fact>]
+ let ``retry after failure reuses the idempotency key until inputs or success change`` () =
+ let attempt = Some { idempotencyKey = "key-abc"; name = "稳健一号"; cash = "100.00" }
+
+ Assert.Equal("key-abc", resolveCreateKey attempt "稳健一号" "100.00")
+ Assert.NotEqual<string>("key-abc", resolveCreateKey attempt "稳健一号" "200.00")
+ Assert.NotEqual<string>("key-abc", resolveCreateKey attempt "另一只" "100.00")
+ Assert.NotEqual<string>("key-abc", resolveCreateKey None "稳健一号" "100.00")
+
+ [<Fact>]
+ let ``create failure keeps the attempt so retry reuses the key`` () =
+ let model =
+ { init () with
+ token = "qa-token"
+ fundSeq = 1
+ fundInFlight = true
+ lastCreateAttempt = Some { idempotencyKey = "key-abc"; name = "稳健一号"; cash = "100.00" } }
+
+ let failed, _ = update (FundCreateFailed(1, "网络中断")) model
+
+ Assert.False(failed.fundInFlight)
+ Assert.Equal(Some "网络中断", failed.error)
+ Assert.Equal(Some { idempotencyKey = "key-abc"; name = "稳健一号"; cash = "100.00" }, failed.lastCreateAttempt)
+
+ [<Fact>]
+ let ``read request without a created fund or token reports guidance`` () =
+ let model = { init () with token = "qa-token" }
+
+ let noFund, _ = update FundReadRequested model
+
+ Assert.Equal(Some "请先创建一个基金", noFund.error)
+ Assert.Equal(0, noFund.fundSeq)
+
+ let noToken, _ = update FundReadRequested { init () with createdFund = Some(sampleFund "some-id") }
+
+ Assert.Equal(Some "请输入 API token", noToken.error)
+ Assert.Equal(0, noToken.fundSeq)
+
+ [<Fact>]
+ let ``fresh read completion refreshes the fund summary`` () =
+ let fund = sampleFund "3fa85f64-5717-4562-b3fc-2c963f66afa6"
+ let model = { init () with token = "qa-token"; fundSeq = 3; fundInFlight = true; createdFund = Some fund }
+
+ let updated, _ = update (FundReadCompleted(3, { fund with availableCash = "12000.00" })) model
+
+ Assert.False(updated.fundInFlight)
+ Assert.Equal("12000.00", updated.createdFund.Value.availableCash)
+
+ let stale, _ = update (FundReadCompleted(2, { fund with availableCash = "1.00" })) updated
+
+ Assert.Equal("12000.00", stale.createdFund.Value.availableCash)