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)