summaryrefslogtreecommitdiff
path: root/tests/FundLab.Web.Tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests/FundLab.Web.Tests')
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs151
-rw-r--r--tests/FundLab.Web.Tests/ChartTests.fs23
-rw-r--r--tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj26
-rw-r--r--tests/FundLab.Web.Tests/Program.fs5
4 files changed, 205 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)
diff --git a/tests/FundLab.Web.Tests/ChartTests.fs b/tests/FundLab.Web.Tests/ChartTests.fs
new file mode 100644
index 0000000..cede2d2
--- /dev/null
+++ b/tests/FundLab.Web.Tests/ChartTests.fs
@@ -0,0 +1,23 @@
+namespace FundLab.Web.Tests
+
+open Xunit
+open FundLab.Web
+
+module ChartTests =
+
+ [<Fact>]
+ let ``chart points preserve chronological order and normalize NAV values`` () =
+ let observations : NavObservation list =
+ [
+ { navDate = "2026-01-01"; nav = "1.00000000"; dailyReturn = None }
+ { navDate = "2026-01-02"; nav = "1.25000000"; dailyReturn = Some "25.00000000" }
+ { navDate = "2026-01-03"; nav = "1.10000000"; dailyReturn = Some "-12.00000000" }
+ ]
+
+ let points = Chart.chartPoints observations
+
+ Assert.Equal(3, points.Length)
+ Assert.Equal(0.0, points[0].x)
+ Assert.Equal(1.0, points[1].y)
+ Assert.Equal(0.4, points[2].y)
+ Assert.Equal("2026-01-03", points[2].navDate)
diff --git a/tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj b/tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj
new file mode 100644
index 0000000..0c1c7f9
--- /dev/null
+++ b/tests/FundLab.Web.Tests/FundLab.Web.Tests.fsproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net8.0</TargetFramework>
+ <OutputType>Library</OutputType>
+ <IsPackable>false</IsPackable>
+ <IsTestProject>true</IsTestProject>
+ <GenerateProgramFile>false</GenerateProgramFile>
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ </PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
+ <PackageReference Include="xunit" Version="2.9.2" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\..\src\FundLab.Web\FundLab.Web.fsproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <Compile Include="ChartTests.fs" />
+ <Compile Include="BoundaryTests.fs" />
+ <Compile Include="Program.fs" />
+ </ItemGroup>
+</Project>
diff --git a/tests/FundLab.Web.Tests/Program.fs b/tests/FundLab.Web.Tests/Program.fs
new file mode 100644
index 0000000..120e538
--- /dev/null
+++ b/tests/FundLab.Web.Tests/Program.fs
@@ -0,0 +1,5 @@
+module FundLab.Web.Tests.Program
+
+[<EntryPoint>]
+let main _argv =
+ 0