summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 02:23:43 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 02:23:43 +0800
commitf55ed58d2c75b53774ef7256fddb973ae3e73323 (patch)
tree63269f233f4305d5b68aa252e9548244a362adcf /tests
parentbe1afd248e4cc9b03a1e21090602a945af6988b2 (diff)
downloadfund-lab-f55ed58d2c75b53774ef7256fddb973ae3e73323.tar.gz
feat(web): 基金创建/读取界面与仓库内浏览器 QA
[变更性质] - 本提交完成 3c-1 切片:Web 端基金建档(创建/重读)表单接入既有幂等 API,并提供仓库内可复现的端到端 QA 入口与生命周期回归。 [新增功能] - 前端新增 03 / CREATE 面板:名称与初始现金(decimal 字符串)输入、创建/重新读取按钮、服务端返回的基金档案行(份额诚实显示 0、来源标注真实建档/合成标记);移动端单列布局。 - 创建请求携带 32 位十六进制 Idempotency-Key;网络失败重试复用同一 key,输入变更或成功后换新 key;token 变更清空档案并按序列号拒绝过期响应。 [实现方案] - 金额全程 decimal 字符串,不经 JS float;update 层 fundSeq 守卫 FundCreate/FundRead 完成消息;BoundaryTests 新增 FundBoundaryTests 9 项(幂等键长度/复用、token 失效、stale 读取拒绝等)。 - qa/run.sh 一次性拉起非默认端口 Postgres/API/Vite(桩行情,_synthetic 标记),清理仅限本次创建的容器(ID+名称归属校验);临时目录 mktemp 隔离;qa/lifecycle-test.sh 验证拒绝路径不破坏既有资源。 [影响范围] - Web 测试增至 17 项,全套 56 项通过;浏览器 QA 30/30 通过;README 更新阶段描述。本提交不包含 docs/overnight-progress.md 的现有修改。
Diffstat (limited to 'tests')
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs177
1 files changed, 177 insertions, 0 deletions
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)