diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 02:23:43 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 02:23:43 +0800 |
| commit | f55ed58d2c75b53774ef7256fddb973ae3e73323 (patch) | |
| tree | 63269f233f4305d5b68aa252e9548244a362adcf /tests/FundLab.Web.Tests/BoundaryTests.fs | |
| parent | be1afd248e4cc9b03a1e21090602a945af6988b2 (diff) | |
| download | fund-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/FundLab.Web.Tests/BoundaryTests.fs')
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 177 |
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) |
