namespace FundLab.Web.Tests open Xunit open FundLab.Web module BoundaryTests = let someText (value: string) : obj = box { case = "Some"; fields = [| value |] } let sampleInstrument code name fundType : Instrument = { code = code; name = name; fundType = fundType } let sampleObservation navDate nav dailyReturn : NavObservation = { navDate = navDate; nav = nav; dailyReturn = dailyReturn } [] let ``decode search converts raw JSON array into F# list with optional fund type`` () = let raw = { instruments = [| { code = "000001"; name = "华夏成长混合"; fundType = someText "混合型" } { code = "110011"; name = "易方达优质精选混合"; fundType = null } |] } let decoded = Api.decodeSearch raw Assert.Equal(2, decoded.Length) Assert.Equal("000001", decoded[0].code) Assert.Equal("华夏成长混合", decoded[0].name) Assert.Equal(Some "混合型", decoded[0].fundType) Assert.Equal(None, decoded[1].fundType) [] let ``decode nav tolerates missing daily return at the boundary`` () = let raw = { observations = [| { navDate = "2026-09-01"; nav = "1.2345"; dailyReturn = someText "0.45" } { navDate = "2026-09-02"; nav = "1.2400"; dailyReturn = null } |] } let decoded = Api.decodeNav raw Assert.Equal(2, decoded.Length) Assert.Equal(Some "0.45", decoded[0].dailyReturn) Assert.Equal(None, decoded[1].dailyReturn) Assert.Equal("2026-09-02", decoded[1].navDate) [] let ``stale search completion does not overwrite newer results`` () = let sentinel = [ sampleInstrument "999999" "旧结果" None ] let model = { init () with searchSeq = 2; searchInFlight = true; searchResults = sentinel } let staleModel, _ = update (SearchCompleted(1, [ sampleInstrument "000001" "新结果" None ])) model Assert.Equal(sentinel, staleModel.searchResults) Assert.True(staleModel.searchInFlight) let freshModel, _ = update (SearchCompleted(2, [ sampleInstrument "000001" "新结果" None ])) model Assert.Equal(1, freshModel.searchResults.Length) Assert.Equal("000001", freshModel.searchResults[0].code) Assert.False(freshModel.searchInFlight) [] let ``stale nav failure does not overwrite newer in-flight state`` () = let selected = sampleInstrument "000001" "华夏成长混合" (Some "混合型") let model = { init () with navSeq = 3; navInFlight = true; selected = Some selected } let staleModel, _ = update (NavFailed(2, "过期失败")) model Assert.True(staleModel.navInFlight) Assert.Equal(None, staleModel.error) let freshModel, _ = update (NavFailed(3, "同步失败")) model Assert.False(freshModel.navInFlight) Assert.Equal(Some "同步失败", freshModel.error) [] let ``changing token clears private results selection and loading state`` () = let model = { init () with token = "old-token" searchResults = [ sampleInstrument "000001" "华夏成长混合" (Some "混合型") ] selected = Some(sampleInstrument "110011" "易方达优质精选混合" None) observations = [ sampleObservation "2026-09-01" "1.2345" (Some "0.45") ] searchInFlight = true navInFlight = true error = Some "旧错误" } let updated, _ = update (TokenChanged "new-token") model Assert.Equal("new-token", updated.token) Assert.Empty(updated.searchResults) Assert.Equal(None, updated.selected) Assert.Empty(updated.observations) Assert.False(updated.searchInFlight) Assert.False(updated.navInFlight) Assert.Equal(None, updated.error) [] let ``token change invalidates in flight search and nav responses`` () = let model = { init () with searchSeq = 1; navSeq = 1 } let cleared, _ = update (TokenChanged "new-token") model Assert.Equal(2, cleared.searchSeq) Assert.Equal(2, cleared.navSeq) let staleSearchModel, _ = update (SearchCompleted(1, [ sampleInstrument "000001" "过期结果" (Some "混合型") ])) cleared Assert.Empty(staleSearchModel.searchResults) let staleSearchFailureModel, _ = update (SearchFailed(1, "过期搜索失败")) cleared Assert.Equal(None, staleSearchFailureModel.error) let staleNavModel, _ = update (NavCompleted(1, [ sampleObservation "2026-09-01" "1.2345" (Some "0.45") ])) cleared Assert.Empty(staleNavModel.observations) let staleNavFailureModel, _ = update (NavFailed(1, "过期净值失败")) cleared Assert.Equal(None, staleNavFailureModel.error) let freshNavModel, _ = update (NavCompleted(2, [ sampleObservation "2026-09-01" "1.2345" None ])) cleared Assert.Equal(1, freshNavModel.observations.Length) [] let ``search failure clears loading and next attempt recovers`` () = let model = { init () with token = "qa-token"; query = "华夏"; searchSeq = 1; searchInFlight = true } let failedModel, _ = update (SearchFailed(1, "collector exited with code 1")) model Assert.False(failedModel.searchInFlight) Assert.Equal(Some "collector exited with code 1", failedModel.error) let retryModel, _ = update SearchRequested failedModel 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" reservedCash = "0.00" status = "active" } [] 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) [] 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) [] 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) [] 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) [] 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) [] 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("key-abc", resolveCreateKey attempt "稳健一号" "200.00") Assert.NotEqual("key-abc", resolveCreateKey attempt "另一只" "100.00") Assert.NotEqual("key-abc", resolveCreateKey None "稳健一号" "100.00") [] 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) [] 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) [] 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) module ConfirmationBoundaryTests = let sampleFundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" let sampleOrderId = "7c9e6679-7425-40de-944b-e07fc1f90ae7" let someText (value: string) : obj = box { case = "Some"; fields = [| value |] } let sampleRawOrder () : RawOrder = { id = sampleOrderId fundCode = "000001" amount = "100.00" feeAmount = "1.50" reservedTotal = "101.50" status = "submitted" submittedAt = "2026-09-21T10:00:00+00:00" tradeDate = "2026-09-21" pendingReason = null confirmedAt = null confirmedNav = null confirmedNavDate = null confirmedUnits = null confirmedInvestedCash = null confirmedResidualCash = null } let sampleConfirmedRaw () : RawOrder = { sampleRawOrder () with status = "confirmed" pendingReason = null confirmedAt = someText "2026-09-21T11:00:00+00:00" confirmedNav = someText "2.50000000" confirmedNavDate = someText "2026-09-21" confirmedUnits = someText "40.00000000" confirmedInvestedCash = someText "100.00" confirmedResidualCash = someText "0.00" } let samplePendingRaw () : RawOrder = { sampleRawOrder () with status = "pending_nav" pendingReason = someText "nav for trade date 2026-09-21 is not available yet" } let samplePositions () : RawPositions = { availableCash = "9900.00" reservedCash = "101.50" positions = [| { instrumentCode = "000001" units = "40.00000000" reservedUnits = "0.00000000" costCash = "100.00" lastConfirmedAt = "2026-09-21T11:00:00+00:00" valuationNav = someText "2.61000000" valuationNavDate = someText "2026-09-21" valuationCollectedAt = someText "2026-09-21T09:30:00+00:00" } { instrumentCode = "110011" units = "10.00000000" reservedUnits = "5.00000000" costCash = "25.00" lastConfirmedAt = "2026-09-21T11:00:00+00:00" valuationNav = null valuationNavDate = null valuationCollectedAt = null } |] } let sampleFund () : FundSummary = { id = sampleFundId name = "稳健一号" currency = "CNY" initialCash = "10000.00" initialUnitNav = "1.00000000" isSynthetic = false availableCash = "9900.00" reservedCash = "101.50" status = "active" } let withOrder model order = { model with token = "qa-token"; createdFund = Some(sampleFund ()); orders = [ order ] } [] let ``optional text decodes plain strings and the legacy case fields envelope`` () = Assert.Equal(Some "1.19020000", Api.decodeOptionalText (box "1.19020000")) Assert.Equal(None, Api.decodeOptionalText null) Assert.Equal(Some "混合型", Api.decodeOptionalText (someText "混合型")) Assert.Equal(None, Api.decodeOptionalText (box { case = "None"; fields = [||] })) [] let ``order decode keeps decimal strings and decodes option fields`` () = let confirmed = Api.decodeOrder (sampleConfirmedRaw ()) let pending = Api.decodeOrder (samplePendingRaw ()) Assert.Equal("confirmed", confirmed.status) Assert.Equal("2.50000000", confirmed.confirmedNav.Value) Assert.Equal("40.00000000", confirmed.confirmedUnits.Value) Assert.Equal("100.00", confirmed.confirmedInvestedCash.Value) Assert.Equal("0.00", confirmed.confirmedResidualCash.Value) Assert.Equal(None, confirmed.pendingReason) Assert.Equal("pending_nav", pending.status) Assert.Equal(Some "nav for trade date 2026-09-21 is not available yet", pending.pendingReason) Assert.Equal(None, pending.confirmedNav) [] let ``positions decode keeps exact strings and flags missing valuation`` () = let decoded = Api.decodePositions (samplePositions ()) Assert.Equal("9900.00", decoded.availableCash) Assert.Equal("101.50", decoded.reservedCash) Assert.Equal(2, decoded.positions.Length) let first = decoded.positions[0] Assert.Equal("40.00000000", first.units) Assert.Equal("100.00", first.costCash) Assert.Equal(Some "2.61000000", first.valuationNav) Assert.Equal(Some "2026-09-21", first.valuationNavDate) let second = decoded.positions[1] Assert.Equal("10.00000000", second.units) Assert.Equal(None, second.valuationNav) Assert.Equal(None, second.valuationNavDate) [] let ``confirm request requires fund token and unique in-flight guard`` () = let noFund, _ = update (OrderConfirmRequested sampleOrderId) { init () with token = "qa-token" } Assert.Equal(0, noFund.confirmSeq) Assert.False(noFund.confirmInFlight) let noToken, _ = update (OrderConfirmRequested sampleOrderId) { init () with createdFund = Some(sampleFund ()) } Assert.Equal(Some "请输入 API token", noToken.error) Assert.Equal(0, noToken.confirmSeq) let started, _ = update (OrderConfirmRequested sampleOrderId) (withOrder { init () with confirmInFlight = false } (Api.decodeOrder (sampleRawOrder ()))) Assert.Equal(1, started.confirmSeq) Assert.True(started.confirmInFlight) Assert.Equal(sampleOrderId, started.lastConfirmAttempt.Value.orderId) Assert.Equal(32, started.lastConfirmAttempt.Value.idempotencyKey.Length) [] let ``confirm completion replaces the order row and keeps exact decimals`` () = let model = withOrder { init () with confirmSeq = 2; confirmInFlight = true; lastConfirmAttempt = Some { idempotencyKey = "k"; orderId = sampleOrderId } } (Api.decodeOrder (sampleRawOrder ())) let updated, _ = update (OrderConfirmCompleted(2, sampleOrderId, sampleConfirmedRaw ())) model Assert.False(updated.confirmInFlight) Assert.Equal(None, updated.lastConfirmAttempt) Assert.Equal(1, updated.orders.Length) let confirmed = updated.orders[0] Assert.Equal("confirmed", confirmed.status) Assert.Equal("2.50000000", confirmed.confirmedNav.Value) Assert.Equal("40.00000000", confirmed.confirmedUnits.Value) [] let ``stale confirm completion is ignored`` () = let model = withOrder { init () with confirmSeq = 5; confirmInFlight = true; lastConfirmAttempt = Some { idempotencyKey = "k"; orderId = sampleOrderId } } (Api.decodeOrder (sampleRawOrder ())) let stale, _ = update (OrderConfirmCompleted(4, sampleOrderId, sampleConfirmedRaw ())) model Assert.True(stale.confirmInFlight) Assert.Equal("submitted", stale.orders[0].status) [] let ``confirm failure clears flight and surfaces error`` () = let model = withOrder { init () with confirmSeq = 1; confirmInFlight = true; lastConfirmAttempt = Some { idempotencyKey = "k"; orderId = sampleOrderId } } (Api.decodeOrder (sampleRawOrder ())) let failed, _ = update (OrderConfirmFailed(1, sampleOrderId, "confirm failed")) model Assert.False(failed.confirmInFlight) Assert.Equal(None, failed.lastConfirmAttempt) Assert.Equal(Some "confirm failed", failed.error) [] let ``positions completion stores payload and stale reads are ignored`` () = let model = { init () with token = "qa-token"; createdFund = Some(sampleFund ()); positionsSeq = 1; positionsInFlight = true } let updated, _ = update (PositionsReadCompleted(1, samplePositions ())) model Assert.False(updated.positionsInFlight) Assert.Equal(Some "9900.00", updated.positions |> Option.map (fun p -> p.availableCash)) Assert.Equal(2, updated.positions.Value.positions.Length) let stale, _ = update (PositionsReadCompleted(0, samplePositions ())) updated Assert.Equal(2, stale.positions.Value.positions.Length) [] let ``token change clears confirm and positions state and invalidates stale responses`` () = let model = { withOrder (init ()) (Api.decodeOrder (sampleRawOrder ())) with token = "old-token" confirmSeq = 3 confirmInFlight = true lastConfirmAttempt = Some { idempotencyKey = "k"; orderId = sampleOrderId } positions = Some(Api.decodePositions (samplePositions ())) positionsSeq = 3 positionsInFlight = true } let cleared, _ = update (TokenChanged "new-token") model Assert.Empty(cleared.orders) Assert.Equal(None, cleared.createdFund) Assert.Equal(None, cleared.positions) Assert.False(cleared.confirmInFlight) Assert.False(cleared.positionsInFlight) Assert.Equal(None, cleared.lastConfirmAttempt) let staleConfirm, _ = update (OrderConfirmCompleted(3, sampleOrderId, sampleConfirmedRaw ())) cleared Assert.Empty(staleConfirm.orders) let stalePositions, _ = update (PositionsReadCompleted(3, samplePositions ())) cleared Assert.Equal(None, stalePositions.positions) module RedemptionBoundaryTests = let sampleRedemptionRaw (status: string) : RawRedemption = { id = "9b2f1c3d-0000-0000-0000-000000000001" instrumentCode = "000001" units = "150.00000000" feeAmount = "1.50" status = status submittedAt = "2026-09-21T10:00:00+00:00" tradeDate = "2026-09-21" pendingReason = null confirmedAt = null confirmedNav = null confirmedNavDate = null confirmedProceeds = null confirmedCostReleased = null } let confirmedRedemptionRaw () : RawRedemption = let someText (value: string) : obj = box { case = "Some"; fields = [| value |] } { sampleRedemptionRaw "confirmed" with pendingReason = null confirmedAt = someText "2026-09-21T11:00:00+00:00" confirmedNav = someText "2.50000000" confirmedNavDate = someText "2026-09-21" confirmedProceeds = someText "373.50" confirmedCostReleased = someText "375.00" } [] let ``redemption decode keeps decimal strings and option fields`` () = let confirmed = Api.decodeRedemption (confirmedRedemptionRaw ()) Assert.Equal("confirmed", confirmed.status) Assert.Equal("150.00000000", confirmed.units) Assert.Equal("1.50", confirmed.feeAmount) Assert.Equal("2.50000000", confirmed.confirmedNav.Value) Assert.Equal("373.50", confirmed.confirmedProceeds.Value) Assert.Equal("375.00", confirmed.confirmedCostReleased.Value) Assert.Equal(None, confirmed.pendingReason) let sampleRedemptionPositions () : RawPositions = { availableCash = "9900.00" reservedCash = "101.50" positions = [| { instrumentCode = "000001" units = "40.00000000" reservedUnits = "0.00000000" costCash = "100.00" lastConfirmedAt = "2026-09-21T11:00:00+00:00" valuationNav = null valuationNavDate = null valuationCollectedAt = null } { instrumentCode = "110011" units = "10.00000000" reservedUnits = "5.00000000" costCash = "25.00" lastConfirmedAt = "2026-09-21T11:00:00+00:00" valuationNav = null valuationNavDate = null valuationCollectedAt = null } |] } [] let ``positions decode keeps reserved units for frozen shares`` () = let decoded = Api.decodePositions (sampleRedemptionPositions ()) Assert.Equal("0.00000000", decoded.positions[0].reservedUnits) Assert.Equal("5.00000000", decoded.positions[1].reservedUnits) module CapitalBoundaryTests = let sampleFund () : FundSummary = { id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" name = "追加资金测试" currency = "CNY" initialCash = "10000.00" initialUnitNav = "1.00000000" isSynthetic = false availableCash = "10000.00" reservedCash = "0.00" status = "active" } [] let ``capital deposit request requires fund token and positive amount`` () = let noFund, _ = update CapitalDepositRequested { init () with token = "qa-token" } Assert.Equal(0, noFund.capitalSeq) Assert.False(noFund.capitalInFlight) let noToken, _ = update CapitalDepositRequested { init () with createdFund = Some(sampleFund ()) } Assert.Equal(Some "请输入 API token", noToken.error) Assert.Equal(0, noToken.capitalSeq) let invalidAmount, _ = update CapitalDepositRequested { init () with token = "qa-token"; createdFund = Some(sampleFund ()); capitalAmount = "0.00" } Assert.Equal(Some "追加金额必须是大于零的两位小数金额,例如 1000.00", invalidAmount.error) Assert.Equal(0, invalidAmount.capitalSeq) [] let ``valid deposit starts one attempt and completion refreshes the fund`` () = let started, _ = update CapitalDepositRequested { init () with token = "qa-token"; createdFund = Some(sampleFund ()); capitalAmount = "500.00" } Assert.Equal(1, started.capitalSeq) Assert.True(started.capitalInFlight) Assert.Equal("500.00", started.lastCapitalAttempt.Value.amount) Assert.Equal(32, started.lastCapitalAttempt.Value.idempotencyKey.Length) let completed, _ = update (CapitalDepositCompleted(1, "500.00")) started Assert.False(completed.capitalInFlight) Assert.Equal(Some "追加成功 + 500.00(现金余额已刷新)", completed.capitalNote) [] let ``stale capital completion is ignored and token change clears the entry`` () = let model = { init () with token = "qa-token" createdFund = Some(sampleFund ()) capitalSeq = 4 capitalInFlight = true lastCapitalAttempt = Some { idempotencyKey = "k"; amount = "500.00" } } let stale, _ = update (CapitalDepositCompleted(3, "500.00")) model Assert.True(stale.capitalInFlight) let cleared, _ = update (TokenChanged "new-token") model Assert.Equal("", cleared.capitalAmount) Assert.Equal(None, cleared.createdFund) Assert.False(cleared.capitalInFlight) Assert.Equal(None, cleared.lastCapitalAttempt) module SipBoundaryTests = let sampleFund () : FundSummary = { id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" name = "定投测试" currency = "CNY" initialCash = "10000.00" initialUnitNav = "1.00000000" isSynthetic = false availableCash = "10000.00" reservedCash = "0.00" status = "active" } let sampleSipRaw () : RawSipPlan = { id = "aa1b2c3d-0000-0000-0000-000000000002" instrumentCode = "000001" amount = "200.00" frequency = "weekly" status = "active" anchorDate = "2026-09-21" nextTradeDate = "2026-09-28" lastExecutionStatus = null lastExecutionDate = null } [] let ``sip plan decode keeps decimal strings and dates untouched`` () = let decoded = Api.decodeSipPlan (sampleSipRaw ()) Assert.Equal("000001", decoded.instrumentCode) Assert.Equal("200.00", decoded.amount) Assert.Equal("weekly", decoded.frequency) Assert.Equal("2026-09-28", decoded.nextTradeDate) Assert.Equal("active", decoded.status) [] let ``sip plan request validates inputs and guards in-flight duplicates`` () = let noFund, _ = update SipPlanCreateRequested { init () with token = "qa-token" } Assert.Equal(0, noFund.sipCreateSeq) Assert.False(noFund.sipInFlight) let noToken, _ = update SipPlanCreateRequested { init () with createdFund = Some(sampleFund ()) } Assert.Equal(Some "请输入 API token", noToken.error) let invalidAmount, _ = update SipPlanCreateRequested { init () with token = "qa-token" createdFund = Some(sampleFund ()) sipCode = "000001" sipAmount = "0.00" } Assert.Equal(Some "定投金额必须是大于零的两位小数金额,例如 200.00", invalidAmount.error) Assert.Equal(0, invalidAmount.sipCreateSeq) let started, _ = update SipPlanCreateRequested { init () with token = "qa-token" createdFund = Some(sampleFund ()) sipCode = "000001" sipAmount = "200.00" } Assert.Equal(1, started.sipCreateSeq) Assert.True(started.sipInFlight) Assert.Equal(32, started.lastSipAttempt.Value.idempotencyKey.Length) let duplicate, _ = update SipPlanCreateRequested started Assert.Equal(started.sipCreateSeq, duplicate.sipCreateSeq) [] let ``stale sip completions do not overwrite newer state`` () = let model = { init () with token = "qa-token" createdFund = Some(sampleFund ()) sipCreateSeq = 2 sipInFlight = true sipPlans = [ Api.decodeSipPlan (sampleSipRaw ()) ] } let stale, _ = update (SipPlanCreateCompleted(1, (sampleFund ()).id, sampleSipRaw ())) model Assert.True(stale.sipInFlight) Assert.Equal(1, stale.sipPlans.Length) let staleList, _ = update (SipPlansReadCompleted(1, [| sampleSipRaw () |])) { model with sipReadSeq = 2 } Assert.Equal(1, staleList.sipPlans.Length)