Skip to content

Commit 7acc55d

Browse files
authored
Merge pull request #10 from BioFSharp/add-tests
Add a behavior test suite for BioFSharp.Mz
2 parents 578fedc + 9fa1be4 commit 7acc55d

23 files changed

Lines changed: 6309 additions & 20 deletions

‎global.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"sdk": {
33
"version": "3.1.300",
4-
"rollForward": "latestFeature"
4+
"rollForward": "latestMajor"
55
}
66
}

‎tests/BioFSharp.Mz.Tests/AndromedaLikeTests.fs‎

Lines changed: 348 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,40 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
5-
<TargetFramework>netcoreapp3.1</TargetFramework>
5+
<TargetFramework>net8.0</TargetFramework>
66
<GenerateProgramFile>false</GenerateProgramFile>
77
</PropertyGroup>
88

99
<ItemGroup>
10-
<Compile Include="Sample.fs" />
10+
<Compile Include="PeakTests.fs" />
11+
<Compile Include="CachingTests.fs" />
12+
<Compile Include="StatsExtensionTests.fs" />
13+
<Compile Include="SignalDetectionTests.fs" />
14+
<Compile Include="FragmentationTests.fs" />
15+
<Compile Include="ChargeStateTests.fs" />
16+
<Compile Include="SearchDBTests.fs" />
17+
<Compile Include="MzIdentMLModelTests.fs" />
18+
<Compile Include="SearchEngineResultTests.fs" />
19+
<Compile Include="SequestLikeTests.fs" />
20+
<Compile Include="AndromedaLikeTests.fs" />
21+
<Compile Include="XScoringTests.fs" />
22+
<Compile Include="SearchEngineInteractionTests.fs" />
23+
<Compile Include="QuantificationTests.fs" />
24+
<Compile Include="FDRControlTests.fs" />
25+
<Compile Include="ProteinInferenceTests.fs" />
26+
<Compile Include="PercolatorWrapperTests.fs" />
27+
<Compile Include="ProteinInferenceVisTests.fs" />
28+
<Compile Include="ParametersTests.fs" />
1129
<Compile Include="Main.fs" />
1230
</ItemGroup>
1331

1432
<ItemGroup>
1533
<PackageReference Include="AltCover" Version="7.3.802" />
16-
<PackageReference Include="Expecto" Version="9.*" />
34+
<PackageReference Include="Expecto" Version="10.*" />
1735
<PackageReference Include="YoloDev.Expecto.TestSdk" Version="0.*" />
18-
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.*" />
36+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
1937
<ProjectReference Include="..\..\src\BioFSharp.Mz\BioFSharp.Mz.fsproj" />
20-
<PackageReference Update="FSharp.Core" Version="4.*" />
38+
<ProjectReference Include="..\..\src\BioFSharp.Mz.Vis\BioFSharp.Mz.Vis.fsproj" />
2139
</ItemGroup>
2240
</Project>
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
module CachingTests
2+
3+
open Expecto
4+
open BioFSharp.Mz
5+
6+
let mkCache () =
7+
let cache = Cache.createCache<int,string>
8+
Cache.addItem cache (20,"b")
9+
Cache.addItem cache (10,"a")
10+
Cache.addItem cache (30,"c")
11+
cache
12+
13+
[<Tests>]
14+
let tests =
15+
testList "CachingTests" [
16+
testCase "addItem inserts keys in ascending sorted order" <| fun _ ->
17+
let cache = mkCache()
18+
Expect.equal cache.Count 3 "cache contains all three items"
19+
Expect.equal (List.ofSeq cache.Keys) [10; 20; 30] "keys are sorted in ascending order"
20+
// SortedList maintains ascending key order regardless of insertion order (BCL contract); addItem delegates to Add for new keys.
21+
22+
testCase "addItem replaces the value of an existing key without changing the count" <| fun _ ->
23+
let cache = Cache.createCache<int,string>
24+
Cache.addItem cache (3,"c")
25+
Cache.addItem cache (3,"z")
26+
Expect.equal cache.Count 1 "re-adding a key keeps one entry"
27+
Expect.equal (Cache.getItemBy cache 3) (true, "z") "the newest value is retained"
28+
// documented add-or-update semantics - re-adding a key must keep one entry holding the newest value.
29+
30+
testCase "getItemBy distinguishes present and missing keys" <| fun _ ->
31+
let cache = mkCache()
32+
Expect.equal (Cache.getItemBy cache 20) (true, "b") "present keys return their value"
33+
Expect.isFalse (fst (Cache.getItemBy cache 15)) "missing keys return a false success flag"
34+
// TryGetValue contract - success flag plus value for hits, false for misses.
35+
36+
testCase "bulkInsertBy inserts every supplied pair" <| fun _ ->
37+
let cache = Cache.createCache<int,string>
38+
Cache.bulkInsertBy cache [(1,"x");(2,"y");(3,"z")] |> ignore
39+
Expect.equal cache.Count 3 "cache contains every supplied pair"
40+
Expect.equal (Cache.getItemBy cache 1) (true, "x") "key 1 is retrievable with its value"
41+
Expect.equal (Cache.getItemBy cache 2) (true, "y") "key 2 is retrievable with its value"
42+
Expect.equal (Cache.getItemBy cache 3) (true, "z") "key 3 is retrievable with its value"
43+
// documented bulk insert - every pair must land in the cache.
44+
45+
testCase "binarySearch Border.Upper resolves each existing key to its sorted index" <| fun _ ->
46+
let cache = mkCache()
47+
Expect.equal (Cache.binarySearch Cache.Border.Upper cache 10) 0 "10 is at sorted index 0"
48+
Expect.equal (Cache.binarySearch Cache.Border.Upper cache 20) 1 "20 is at sorted index 1"
49+
Expect.equal (Cache.binarySearch Cache.Border.Upper cache 30) 2 "30 is at sorted index 2"
50+
// keys sorted ascending are [10;20;30], so their indices are 0,1,2 by definition of sorted
51+
// position. Note: only 20 is found by the search loop itself; 10 and 30 terminate as misses
52+
// and are resolved to the correct index by the Border.Upper miss fallback - the expectation is
53+
// contractually right for Border.Upper but does NOT extend to Border.Lower (see the pending
54+
// Border.Lower test).
55+
56+
testCase "binarySearch Border.Upper clamps a probe above all keys to the last index" <| fun _ ->
57+
Expect.equal (Cache.binarySearch Cache.Border.Upper (mkCache()) 99) 2 "probe above all keys clamps to the last index"
58+
// this clamp is the contract relied upon by SearchEngineGeneric (which feeds the result directly into getValuesByIdx as an upper index when a query range extends past the cache); any sensible corrected implementation (last key <= probe, or first key >= probe clamped to Count-1) also returns 2 for probe 99, so the assertion is fix-compatible, not bug-locking.
59+
60+
// PENDING: for keys [10;20;30;40;50], the existing key 20 sits at sorted index 1, and a
61+
// search that claims to locate keys must return it for either border. The early-terminating
62+
// loop misses the key and the Border.Lower fallback subtracts one, returning 0. On a 3-key
63+
// cache the loop happens to compare key 20 directly, so the bug does not manifest there -
64+
// the 5-key fixture is required.
65+
ptestCase "binarySearch Border.Lower resolves an existing key to its sorted index" <| fun _ ->
66+
let cache = Cache.createCache<int,string>
67+
Cache.addItem cache (10,"a")
68+
Cache.addItem cache (20,"b")
69+
Cache.addItem cache (30,"c")
70+
Cache.addItem cache (40,"d")
71+
Cache.addItem cache (50,"e")
72+
Expect.equal (Cache.binarySearch Cache.Border.Lower cache 20) 1 "20 is at sorted index 1"
73+
74+
testCase "getItemsByIdx returns the inclusive index range" <| fun _ ->
75+
let cache = mkCache()
76+
Expect.equal (Cache.getItemsByIdx cache (0,1)) [(10,"a");(20,"b")] "indices 0 through 1 are included"
77+
// documented inclusive index range over the sorted list.
78+
79+
testCase "getValuesByIdx returns the values of the inclusive index range" <| fun _ ->
80+
Expect.equal (Cache.getValuesByIdx (mkCache()) (1,2)) ["b";"c"] "values at indices 1 through 2 are included"
81+
// same inclusive range contract, values only.
82+
83+
testCase "getItemsByRange with existing endpoint keys returns exactly the items in that key range" <| fun _ ->
84+
Expect.equal (Cache.getItemsByRange (mkCache()) (20,30)) [(20,"b");(30,"c")] "both existing endpoints and their range are included"
85+
// documented inclusive key range; both endpoints are existing keys so no gap-resolution semantics are involved.
86+
87+
testCase "containsItemsBetween reports the index bounds of a range holding two keys" <| fun _ ->
88+
Expect.equal (Cache.containsItemsBetween (mkCache()) (15,35)) (Some (1,2)) "keys 20 and 30 occupy indices 1 and 2"
89+
// keys 20 and 30 lie inside [15,35] and sit at sorted indices 1 and 2 (hand-computed from the sorted key order [10;20;30]).
90+
91+
testCase "containsItemsBetween index bounds are minimal and maximal for existing endpoints" <| fun _ ->
92+
let cache = Cache.createCache<int,string>
93+
Cache.addItem cache (10,"a")
94+
Cache.addItem cache (20,"b")
95+
Cache.addItem cache (30,"c")
96+
Cache.addItem cache (45,"d")
97+
Cache.addItem cache (60,"e")
98+
Cache.addItem cache (80,"f")
99+
Expect.equal (Cache.containsItemsBetween cache (20, 60)) (Some (1, 4)) "existing endpoints resolve to their exact sorted indices"
100+
Expect.equal (Cache.getValuesByIdx cache (1, 4)) ["b";"c";"d";"e"] "the inclusive bounds return every value in the endpoint range"
101+
// both endpoints are existing keys, so by inclusive-interval membership the bounds must be exactly their sorted indices - this pins lower-bound MINIMALITY, which the general invariant test (upper maximality only) cannot: an implementation skipping the first qualifying key would pass it. Consumers feed these indices straight into getValuesByIdx; a high lower bound silently drops candidates.
102+
103+
testCase "a custom comparer governs ordering and search" <| fun _ ->
104+
let reverseComparer = { new System.Collections.Generic.IComparer<int> with member _.Compare(a, b) = compare b a }
105+
let cache = Cache.createCacheWith reverseComparer 1
106+
Cache.addItem cache (20,"b")
107+
Cache.addItem cache (10,"a")
108+
Cache.addItem cache (30,"c")
109+
Expect.equal (List.ofSeq cache.Keys) [30;20;10] "the supplied comparer orders keys in descending order"
110+
Expect.equal (Cache.binarySearch Cache.Border.Upper cache 30) 0 "30 is at sorted index 0 under the custom comparer"
111+
Expect.equal (Cache.binarySearch Cache.Border.Upper cache 20) 1 "20 is at sorted index 1 under the custom comparer"
112+
Expect.equal (Cache.binarySearch Cache.Border.Upper cache 10) 2 "10 is at sorted index 2 under the custom comparer"
113+
Expect.equal cache.Count 3 "all items are retained after automatic growth"
114+
// the supplied comparer dictates sorted order (SortedList contract) and binarySearch must consult the cache's own comparer; growing past the initial capacity of 1 also exercises documented automatic growth without pinning a capacity value.
115+
116+
testCase "containsItemsBetween returns None for ranges holding no keys" <| fun _ ->
117+
let cache = mkCache()
118+
Expect.equal (Cache.containsItemsBetween cache (12,18)) None "a gap between keys contains no cached key"
119+
Expect.equal (Cache.containsItemsBetween cache (40,50)) None "a range above all keys contains no cached key"
120+
Expect.equal (Cache.containsItemsBetween cache (0,5)) None "a range below all keys contains no cached key"
121+
// these ranges contain no cached key, and production (SearchDB/SearchEngineGeneric) uses Some/None as the sole guard deciding whether cached data is served - a false Some silently yields wrong peptide lists.
122+
123+
testCase "containsItemsBetween index bounds always point at keys inside the range" <| fun _ ->
124+
let cache = Cache.createCache<int,string>
125+
Cache.addItem cache (10,"a")
126+
Cache.addItem cache (20,"b")
127+
Cache.addItem cache (30,"c")
128+
Cache.addItem cache (45,"d")
129+
Cache.addItem cache (60,"e")
130+
Cache.addItem cache (80,"f")
131+
let ranges = [(15,50); (10,80); (25,70); (55,90)]
132+
for range in ranges do
133+
let lowerMass, upperMass = range
134+
match Cache.containsItemsBetween cache range with
135+
| Some (lo, hi) ->
136+
for idx in lo .. hi do
137+
let key = cache.Keys.[idx]
138+
Expect.isTrue (key >= lowerMass && key <= upperMass) (sprintf "key %d at index %d lies inside range [%d,%d]" key idx lowerMass upperMass)
139+
Expect.isTrue (cache.Keys.[hi] <= upperMass) (sprintf "key at upper index %d is at or below upper bound %d" hi upperMass)
140+
Expect.isTrue (hi = cache.Count - 1 || cache.Keys.[hi + 1] > upperMass) (sprintf "upper index %d is maximal for upper bound %d" hi upperMass)
141+
| None ->
142+
failtestf "range [%d,%d] must report at least two cached keys" lowerMass upperMass
143+
// SearchDB reads Keys.[upperMassIdx] and re-queries the database from that key upward, so a hi bound pointing at a key outside the range (or not the maximal in-range key) corrupts the re-query window - this invariant is what the caller stakes correctness on.
144+
145+
testCase "containsItemsBetween returns None for an empty cache" <| fun _ ->
146+
let cache = Cache.createCache<int,string>
147+
Expect.equal (Cache.containsItemsBetween cache (0,100)) None "an empty cache has no contained items"
148+
// an empty cache contains no items in any range.
149+
150+
// PENDING: the range [15,25] contains exactly the key 20, and the doc says border items are included, so the function should report it. The implementation demands strictly different lower/upper indices and returns None for single-key ranges.
151+
ptestCase "containsItemsBetween reports a range containing exactly one key" <| fun _ ->
152+
Expect.equal (Cache.containsItemsBetween (mkCache()) (15,25)) (Some (1,1)) "the single contained key 20 occupies both bounds"
153+
154+
// PENDING: only key 20 lies inside [15,25], but the implementation returns ALL THREE items:
155+
// the lower probe resolves the missing bound to index 0 (leaking key 10 below the range) and
156+
// the upper probe clamps to the last index (leaking key 30 above it). Both ends leak.
157+
ptestCase "getItemsByRange excludes keys outside the range" <| fun _ ->
158+
Expect.equal (Cache.getItemsByRange (mkCache()) (15,25)) [(20,"b")] "keys below the lower bound are excluded"
159+
160+
// PENDING: a range query over an empty collection must be empty (as containsItemsBetween
161+
// already answers None); the implementation dereferences index 0 and throws
162+
// ArgumentOutOfRangeException.
163+
ptestCase "getItemsByRange over an empty cache is empty" <| fun _ ->
164+
Expect.equal (Cache.getItemsByRange (Cache.createCache<int,string>) (0,5)) [] "an empty cache has no items in the range"
165+
166+
// PENDING: doc says items with keys smaller than the cutoff are deleted, so [20;30] must survive. The implementation adds the survivors back into the ORIGINAL cache (duplicate-key ArgumentException on the first re-add) and returns the empty replacement cache.
167+
// The gap cutoff 15 must yield the same survivors - a fix that keys off binarySearch Upper 15 (= index 0) would wrongly retain key 10.
168+
ptestCase "bulkDeleteBy removes items below the cutoff and returns the surviving items" <| fun _ ->
169+
let cleaned = Cache.bulkDeleteBy (mkCache()) 20
170+
Expect.equal (List.ofSeq cleaned.Keys) [20;30] "items at and above the cutoff survive"
171+
let cleanedGap = Cache.bulkDeleteBy (mkCache()) 15
172+
Expect.equal (List.ofSeq cleanedGap.Keys) [20;30] "items at and above a gap cutoff survive"
173+
]

0 commit comments

Comments
 (0)