1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
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)
|