|
| 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