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)