diff options
Diffstat (limited to 'tests/FundLab.Web.Tests/BoundaryTests.fs')
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 151 |
1 files changed, 151 insertions, 0 deletions
diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs new file mode 100644 index 0000000..9059cef --- /dev/null +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -0,0 +1,151 @@ +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 } + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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<Instrument>(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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) + + [<Fact>] + 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) |
