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 ``nav series decodes points with optional accumulated nav`` () = let raw : RawMarketNavSeriesPoint array = [| { navDate = "2026-09-21"; nav = "1.19020000"; accumulatedNav = null } { navDate = "2026-09-18"; nav = "1.15000000"; accumulatedNav = someText "2.53000000" } |] let decoded = raw |> Array.map Api.decodeNavSeriesPoint |> Array.toList Assert.Equal(2, decoded.Length) Assert.Equal("2026-09-21", decoded[0].navDate) Assert.Equal("1.19020000", decoded[0].nav) Assert.Equal(None, decoded[0].accumulatedNav) Assert.Equal(Some "2.53000000", decoded[1].accumulatedNav) [] let ``nav series read completion stores points and flags availability`` () = let model = init () let raw = { code = "000001"; points = [| { navDate = "2026-09-21"; nav = "1.1902"; accumulatedNav = null } |] } let updated, _ = update (NavSeriesReadCompleted(model.navSeriesReadSeq, raw)) model Assert.Equal(1, updated.navSeries.Length) Assert.Equal("1.1902", updated.navSeries[0].nav) Assert.True(updated.navSeriesAvailable) [] let ``nav series read failure clears the chart without a zero fill`` () = let model = { init () with navSeries = [ { navDate = "2026-09-21"; nav = "1.1902"; accumulatedNav = None } ] navSeriesAvailable = true navSeriesInFlight = true } let updated, _ = update (NavSeriesReadFailed(model.navSeriesReadSeq, "collector exited with code 2")) model Assert.Empty updated.navSeries Assert.False(updated.navSeriesAvailable) Assert.False(updated.navSeriesInFlight) [] let ``bond quote completion stores decoded quote and clears the message`` () = let model = init () let raw : RawBondQuote = { code = "110075" name = box "南航转债" price = box "106.368" cleanPrice = null accruedInterest = null date = null maturityDate = null } let updated, _ = update (BondQuoteCompleted(model.bondQuoteSeq, raw)) model Assert.Equal(Some "南航转债", updated.bondQuote |> Option.bind (fun quote -> quote.name)) Assert.Equal(Some "106.368", updated.bondQuote |> Option.bind (fun quote -> quote.price)) Assert.Equal(None, updated.bondQuote |> Option.bind (fun quote -> quote.maturityDate)) Assert.Equal(None, updated.bondQuoteMessage) Assert.False(updated.bondQuoteInFlight) [] let ``bond quote completion keeps clean price, accrued interest and quote date`` () = let model = init () let raw : RawBondQuote = { code = "019547" name = null price = box "119.858" cleanPrice = box "119.858" accruedInterest = box "0.5000" date = box "2026-09-21" maturityDate = null } let updated, _ = update (BondQuoteCompleted(model.bondQuoteSeq, raw)) model Assert.Equal(Some "119.858", updated.bondQuote |> Option.bind (fun quote -> quote.cleanPrice)) Assert.Equal(Some "0.5000", updated.bondQuote |> Option.bind (fun quote -> quote.accruedInterest)) Assert.Equal(Some "2026-09-21", updated.bondQuote |> Option.bind (fun quote -> quote.quoteDate)) [] let ``bond quote failure shows an unavailable message without a quote`` () = let model = { init () with bondQuoteInFlight = true } let updated, _ = update (BondQuoteFailed(model.bondQuoteSeq, "AKShare collector failed: no valid bond quote for 019547")) model Assert.Equal(None, updated.bondQuote) let message = updated.bondQuoteMessage |> Option.defaultValue "" Assert.Contains("不可用", message) Assert.Contains("no valid bond quote for 019547", message) Assert.False(updated.bondQuoteInFlight) [] let ``stock quote completion stores decoded quote and clears the message`` () = let model = init () let raw : RawStockQuote = { code = "600519"; name = box "贵州茅台"; price = box "1252.57"; currency = "CNY" } let updated, _ = update (StockQuoteCompleted(model.stockQuoteSeq, raw)) model Assert.Equal(Some "贵州茅台", updated.stockQuote |> Option.bind (fun quote -> quote.name)) Assert.Equal(Some "1252.57", updated.stockQuote |> Option.bind (fun quote -> quote.price)) Assert.Equal(Some "CNY", updated.stockQuote |> Option.map (fun quote -> quote.currency)) Assert.Equal(None, updated.stockQuoteMessage) Assert.False(updated.stockQuoteInFlight) [] let ``stock quote failure shows an unavailable message without a quote`` () = let model = { init () with stockQuoteInFlight = true } let updated, _ = update (StockQuoteFailed(model.stockQuoteSeq, "collector exited with code 2")) model Assert.Equal(None, updated.stockQuote) Assert.Contains("股票行情不可用", updated.stockQuoteMessage |> Option.defaultValue "") Assert.False(updated.stockQuoteInFlight) [] let ``stock quote invalid code shows an invalid code message`` () = let model = { init () with stockQuoteInFlight = true } let updated, _ = update (StockQuoteFailed(model.stockQuoteSeq, "stock code must contain exactly six digits")) model Assert.Contains("无效代码", updated.stockQuoteMessage |> Option.defaultValue "") [] let ``stock daily completion stores ascending rows`` () = let model = init () let raw : RawStockDaily = { code = "600519" observations = [| { date = "2026-09-17"; close = "1266.98"; volume = null; amount = null } { date = "2026-09-18"; close = "1257.12"; volume = null; amount = null } { date = "2026-09-21"; close = "1252.57"; volume = null; amount = null } |] } let updated, _ = update (StockDailyCompleted(model.stockDailySeq, raw)) model Assert.Equal(3, updated.stockDaily.Length) Assert.Equal("2026-09-17", updated.stockDaily[0].date) Assert.Equal("1252.57", updated.stockDaily[2].close) Assert.Equal(None, updated.stockDailyMessage) [] let ``stock daily failure shows an unavailable message without rows`` () = let model = { init () with stockDailyInFlight = true } let updated, _ = update (StockDailyFailed(model.stockDailySeq, "AKShare collector failed: no valid daily history for 999999")) model Assert.Empty updated.stockDaily Assert.Contains("股票日线不可用", updated.stockDailyMessage |> Option.defaultValue "") Assert.False(updated.stockDailyInFlight) [] let ``stock buy requires a created fund before dispatching a trade`` () = let model = { init () with token = "qa-token"; stockCode = "600519"; stockTradeQuantity = "100" } let updated, _ = update StockTradeRequested model Assert.Contains("请先创建一个基金", updated.stockTradeMessage |> Option.defaultValue "") Assert.False(updated.stockTradeInFlight) [] let ``stock buy rejects a non-positive quantity without dispatching`` () = let model = { init () with token = "qa-token" createdFund = Some(FundBoundaryTests.sampleFund "3fa85f64-5717-4562-b3fc-2c963f66afa6") stockCode = "600519" stockTradeQuantity = "0" } let updated, _ = update StockTradeRequested model Assert.Contains("数量必须是大于零的整数股数", updated.stockTradeMessage |> Option.defaultValue "") Assert.False(updated.stockTradeInFlight) [] let ``stock buy failure keeps no position and surfaces a message`` () = let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" let model = { init () with token = "qa-token" createdFund = Some(FundBoundaryTests.sampleFund fundId) stockCode = "999999" stockTradeQuantity = "100" stockTradeInFlight = true stockTradeSeq = 1 } let updated, _ = update (StockTradeFailed(1, fundId, "collector exited with code 2")) model Assert.Empty updated.stockPositions Assert.Contains("买入失败", updated.stockTradeMessage |> Option.defaultValue "") Assert.False(updated.stockTradeInFlight) [] let ``stock positions read completion stores decoded rows`` () = let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" let model = { init () with createdFund = Some(FundBoundaryTests.sampleFund fundId) stockPositionsReadSeq = 1 stockPositionsInFlight = true } let raw : RawStockPositions = { fundId = fundId positions = [| { instrumentCode = "600519" stockName = box "贵州茅台" quantity = "100" costCash = "125257.00" lastTradedAt = "2026-09-21T08:00:00Z" } |] } let updated, _ = update (StockPositionsReadCompleted(1, fundId, raw)) model Assert.Equal(1, updated.stockPositions.Length) Assert.Equal("600519", updated.stockPositions[0].instrumentCode) Assert.Equal(Some "贵州茅台", updated.stockPositions[0].stockName) Assert.Equal("100", updated.stockPositions[0].quantity) Assert.False(updated.stockPositionsInFlight) [] let ``bond buy failure keeps no position and surfaces a message`` () = let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" let model = { init () with token = "qa-token" createdFund = Some(FundBoundaryTests.sampleFund fundId) bondCode = "999999" bondTradeQuantity = "10" bondTradeInFlight = true bondTradeSeq = 1 } let updated, _ = update (BondTradeFailed(1, fundId, "collector exited with code 2")) model Assert.Empty updated.bondPositions Assert.Contains("买入失败", updated.bondTradeMessage |> Option.defaultValue "") Assert.False(updated.bondTradeInFlight) [] let ``bond positions read completion stores decoded rows`` () = let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" let model = { init () with createdFund = Some(FundBoundaryTests.sampleFund fundId) bondPositionsReadSeq = 1 bondPositionsInFlight = true } let raw : RawBondPositions = { fundId = fundId positions = [| { instrumentCode = "110075" bondName = box "南航转债" quantity = "10" costCash = "1063.68" lastTradedAt = "2026-09-21T08:00:00Z" } |] } let updated, _ = update (BondPositionsReadCompleted(1, fundId, raw)) model Assert.Equal(1, updated.bondPositions.Length) Assert.Equal("110075", updated.bondPositions[0].instrumentCode) Assert.Equal(Some "南航转债", updated.bondPositions[0].bondName) Assert.Equal("10", updated.bondPositions[0].quantity) Assert.False(updated.bondPositionsInFlight) [] let ``valuation completion stores totals and per-position marks`` () = let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" let model = { init () with createdFund = Some(FundBoundaryTests.sampleFund fundId) valuationReadSeq = 1 valuationInFlight = true } let raw : RawFundValuation = { fundId = fundId currency = "CNY" cash = "10000.00" positionsMarketValue = "126320.68" portfolioValue = "136320.68" pricedPositions = 1 unavailablePositions = 1 positions = [| { instrumentCode = "600519" name = box "贵州茅台" assetClass = "stock" quantity = "100.00000000" price = box "1252.57" priceSource = box "live" marketValue = box "125257.00" status = "priced" } { instrumentCode = "110075" name = box "南航转债" assetClass = "bond" quantity = "10.00000000" price = null priceSource = null marketValue = null status = "unavailable" } |] } let updated, _ = update (ValuationReadCompleted(1, fundId, raw)) model let valuation = updated.valuation.Value Assert.Equal("136320.68", valuation.portfolioValue) Assert.Equal(1, valuation.unavailablePositions) Assert.Equal(2, valuation.positions.Length) Assert.Equal("unavailable", valuation.positions[1].status) Assert.False(updated.valuationInFlight) [] let ``valuation failure clears totals and surfaces a message`` () = let fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" let model = { init () with createdFund = Some(FundBoundaryTests.sampleFund fundId) valuationReadSeq = 1 valuationInFlight = true } let updated, _ = update (ValuationReadFailed(1, fundId, "collector exited with code 2")) model Assert.Equal(None, updated.valuation) Assert.Contains("组合估值不可用", updated.valuationMessage |> Option.defaultValue "") Assert.False(updated.valuationInFlight) [] 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) module RebalanceBoundaryTests = open Xunit let sampleFundInfo () : FundSummary = { id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" name = "再平衡测试" currency = "CNY" initialCash = "20000.00" initialUnitNav = "1.00000000" isSynthetic = false availableCash = "19500.00" reservedCash = "0.00" status = "active" } let sampleRebalanceRaw () : RawRebalancePlan = { id = "bb2c3d4e-0000-0000-0000-000000000003" fundId = "3fa85f64-5717-4562-b3fc-2c963f66afa6" targets = [| { instrumentCode = "000001"; targetPercent = "60.00" } { instrumentCode = "000002"; targetPercent = "40.00" } |] status = "active" createdAt = "2026-09-21T10:00:00+00:00" } let sampleExecutionRaw () : RawRebalanceExecution = { planId = "cc3d4e5f-0000-0000-0000-000000000004" runDate = "2026-09-21" outcomes = [| { instrumentCode = "000002" action = "buy" amount = "4800.00" status = "succeeded" orderId = box "9d8c7b6a-0000-0000-0000-000000000009" pendingReason = null } |] } [] let ``rebalance decode keeps decimal strings per target`` () = let decoded = Api.decodeRebalancePlan (sampleRebalanceRaw ()) Assert.Equal(2, decoded.targets.Length) Assert.Equal("000001", decoded.targets[0].instrumentCode) Assert.Equal("60.00", decoded.targets[0].targetPercent) Assert.Equal("40.00", decoded.targets[1].targetPercent) Assert.Equal("active", decoded.status) [] let ``rebalance execution decodes outcome fields and refreshes orders`` () = let started, _ = update (RebalanceExecuteRequested "cc3d4e5f-0000-0000-0000-000000000004") { init () with token = "qa-token"; createdFund = Some(sampleFundInfo ()); rebalExecuteSeq = 0 } Assert.Equal(1, started.rebalExecuteSeq) Assert.True(started.rebalExecuteInFlight) let executed, _ = update (RebalanceExecuted(1, sampleExecutionRaw ())) started Assert.False(executed.rebalExecuteInFlight) let result = executed.rebalResult.Value Assert.Equal(1, result.Length) Assert.Equal("buy", result[0].action) Assert.Equal("4800.00", result[0].amount) Assert.Equal("9d8c7b6a-0000-0000-0000-000000000009", result[0].orderId.Value) Assert.Equal(None, result[0].pendingReason) [] let ``stale rebalance executions are ignored`` () = let model = { init () with token = "qa-token"; createdFund = Some(sampleFundInfo ()); rebalExecuteSeq = 3; rebalExecuteInFlight = true } let stale, _ = update (RebalanceExecuted(2, sampleExecutionRaw ())) model Assert.True(stale.rebalExecuteInFlight) Assert.Equal(None, stale.rebalResult)