View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.apache.juneau.commons.collections;
18  
19  import static org.apache.juneau.commons.collections.CacheMode.*;
20  import static org.apache.juneau.junit.bct.BctAssertions.*;
21  import static org.junit.jupiter.api.Assertions.*;
22  
23  import java.util.*;
24  import java.util.concurrent.*;
25  import java.util.concurrent.atomic.*;
26  
27  import org.apache.juneau.*;
28  import org.junit.jupiter.api.*;
29  
30  class Cache_Test extends TestBase {
31  
32  	//====================================================================================================
33  	// Basic cache operations - get, hit, miss
34  	//====================================================================================================
35  
36  	@Test void a01_basicGet_cacheMiss() {
37  		var cache = Cache.of(String.class, String.class).build();
38  		var callCount = new AtomicInteger();
39  
40  		var result = cache.get("key1", () -> {
41  			callCount.incrementAndGet();
42  			return "value1";
43  		});
44  
45  		assertEquals("value1", result);
46  		assertEquals(1, callCount.get());
47  		assertSize(1, cache);
48  		assertEquals(0, cache.getCacheHits());
49  	}
50  
51  	@Test void a02_basicGet_cacheHit() {
52  		var cache = Cache.of(String.class, String.class).build();
53  		var callCount = new AtomicInteger();
54  
55  		// First call - cache miss
56  		var result1 = cache.get("key1", () -> {
57  			callCount.incrementAndGet();
58  			return "value1";
59  		});
60  
61  		// Second call - cache hit
62  		var result2 = cache.get("key1", () -> {
63  			callCount.incrementAndGet();
64  			return "should not be called";
65  		});
66  
67  		assertEquals("value1", result1);
68  		assertEquals("value1", result2);
69  		assertSame(result1, result2); // Same instance
70  		assertEquals(1, callCount.get()); // Supplier only called once
71  		assertSize(1, cache);
72  		assertEquals(1, cache.getCacheHits());
73  	}
74  
75  	@Test void a03_multipleKeys() {
76  		var cache = Cache.of(String.class, Integer.class).build();
77  
78  		var v1 = cache.get("one", () -> 1);
79  		var v2 = cache.get("two", () -> 2);
80  		var v3 = cache.get("three", () -> 3);
81  
82  		assertEquals(1, v1);
83  		assertEquals(2, v2);
84  		assertEquals(3, v3);
85  		assertSize(3, cache);
86  		assertEquals(0, cache.getCacheHits());
87  
88  		// Verify all cached
89  		var v1Again = cache.get("one", () -> 999);
90  		var v2Again = cache.get("two", () -> 999);
91  		var v3Again = cache.get("three", () -> 999);
92  
93  		assertEquals(1, v1Again);
94  		assertEquals(2, v2Again);
95  		assertEquals(3, v3Again);
96  		assertEquals(3, cache.getCacheHits());
97  	}
98  
99  	//====================================================================================================
100 	// Null key handling
101 	//====================================================================================================
102 
103 	@Test void a04_nullKey_allowed() {
104 		var cache = Cache.of(String.class, String.class)
105 			.supplier(k -> "value-" + k)
106 			.build();
107 
108 		// Null keys are now allowed
109 		assertEquals("value-null", cache.get(null, () -> "value-null"));
110 
111 		// Verify caching works with null keys
112 		assertEquals("value-null", cache.get(null)); // Cached (hit #1)
113 		assertEquals(1, cache.getCacheHits());
114 
115 		assertEquals("value-null", cache.get(null)); // Cached (hit #2)
116 		assertEquals(2, cache.getCacheHits());
117 	}
118 
119 	//====================================================================================================
120 	// Size and clear operations
121 	//====================================================================================================
122 
123 	@Test void a06_size() {
124 		var cache = Cache.of(String.class, Integer.class).build();
125 
126 		assertEmpty(cache);
127 
128 		cache.get("one", () -> 1);
129 		assertSize(1, cache);
130 
131 		cache.get("two", () -> 2);
132 		assertSize(2, cache);
133 
134 		cache.get("three", () -> 3);
135 		assertSize(3, cache);
136 
137 		// Accessing existing key doesn't change size
138 		cache.get("one", () -> 999);
139 		assertSize(3, cache);
140 	}
141 
142 	@Test void a07_clear() {
143 		var cache = Cache.of(String.class, Integer.class).build();
144 
145 		cache.get("one", () -> 1);
146 		cache.get("two", () -> 2);
147 		cache.get("one", () -> 999); // Cache hit
148 
149 		assertSize(2, cache);
150 		assertEquals(1, cache.getCacheHits());
151 
152 		cache.clear();
153 
154 		assertEmpty(cache);
155 		assertEquals(1, cache.getCacheHits()); // Hits not cleared
156 
157 		// Values must be recomputed after clear
158 		var v1 = cache.get("one", () -> 10);
159 		assertEquals(10, v1);
160 		assertSize(1, cache);
161 	}
162 
163 	//====================================================================================================
164 	// Max size and eviction
165 	//====================================================================================================
166 
167 	@Test void a08_maxSize_eviction() {
168 		var cache = Cache.of(String.class, Integer.class)
169 			.maxSize(3)
170 			.build();
171 
172 		cache.get("one", () -> 1);
173 		cache.get("two", () -> 2);
174 		cache.get("three", () -> 3);
175 		assertSize(3, cache);
176 
177 		// 4th item doesn't trigger eviction (size > maxSize means 4 > 3)
178 		cache.get("four", () -> 4);
179 		assertSize(4, cache);
180 
181 		// 5th item triggers eviction (size=4, 4 > 3, so clear then add)
182 		cache.get("five", () -> 5);
183 		assertSize(1, cache); // Only the new item
184 	}
185 
186 	@Test void a09_maxSize_custom() {
187 		var cache = Cache.of(String.class, String.class)
188 			.maxSize(5)
189 			.build();
190 
191 		for (var i = 1; i <= 5; i++) {
192 			final int index = i;
193 			cache.get("key" + index, () -> "value" + index);
194 		}
195 		assertSize(5, cache);
196 
197 		// 6th item doesn't trigger clear yet (5 > 5 is false)
198 		cache.get("key6", () -> "value6");
199 		assertSize(6, cache);
200 
201 		// 7th item triggers clear (6 > 5 is true)
202 		cache.get("key7", () -> "value7");
203 		assertSize(1, cache);
204 	}
205 
206 	//====================================================================================================
207 	// Disabled cache
208 	//====================================================================================================
209 
210 	@Test void a10_disabled_neverCaches() {
211 		var cache = Cache.of(String.class, String.class)
212 			.cacheMode(NONE)
213 			.build();
214 		var callCount = new AtomicInteger();
215 
216 		var result1 = cache.get("key1", () -> {
217 			callCount.incrementAndGet();
218 			return "value" + callCount.get();
219 		});
220 
221 		var result2 = cache.get("key1", () -> {
222 			callCount.incrementAndGet();
223 			return "value" + callCount.get();
224 		});
225 
226 		assertEquals("value1", result1);
227 		assertEquals("value2", result2);
228 		assertEquals(2, callCount.get()); // Always calls supplier
229 		assertEmpty(cache);
230 		assertEquals(0, cache.getCacheHits());
231 	}
232 
233 	@Test void a11_disabled_sizeAlwaysZero() {
234 		var cache = Cache.of(String.class, Integer.class)
235 			.cacheMode(NONE)
236 			.build();
237 
238 		cache.get("one", () -> 1);
239 		cache.get("two", () -> 2);
240 		cache.get("three", () -> 3);
241 
242 		assertEmpty(cache);
243 	}
244 
245 	@Test void a12_disabled_clearHasNoEffect() {
246 		var cache = Cache.of(String.class, Integer.class)
247 			.cacheMode(NONE)
248 			.build();
249 
250 		cache.clear(); // Should not throw
251 		assertEmpty(cache);
252 	}
253 
254 	//====================================================================================================
255 	// Weak cache mode
256 	//====================================================================================================
257 
258 	@Test void a13_weakMode_basicCaching() {
259 		var cache = Cache.of(String.class, String.class)
260 			.cacheMode(WEAK)
261 			.build();
262 		var callCount = new AtomicInteger();
263 
264 		// First call - cache miss
265 		var result1 = cache.get("key1", () -> {
266 			callCount.incrementAndGet();
267 			return "value1";
268 		});
269 
270 		// Second call - cache hit
271 		var result2 = cache.get("key1", () -> {
272 			callCount.incrementAndGet();
273 			return "should not be called";
274 		});
275 
276 		assertEquals("value1", result1);
277 		assertEquals("value1", result2);
278 		assertSame(result1, result2);
279 		assertEquals(1, callCount.get()); // Supplier only called once
280 		assertSize(1, cache);
281 		assertEquals(1, cache.getCacheHits());
282 	}
283 
284 	@Test void a14_weakMode_multipleKeys() {
285 		var cache = Cache.of(String.class, Integer.class)
286 			.cacheMode(WEAK)
287 			.build();
288 
289 		cache.get("one", () -> 1);
290 		cache.get("two", () -> 2);
291 		cache.get("three", () -> 3);
292 
293 		assertSize(3, cache);
294 		assertEquals(0, cache.getCacheHits());
295 
296 		// Verify all cached
297 		assertEquals(1, cache.get("one", () -> 999));
298 		assertEquals(2, cache.get("two", () -> 999));
299 		assertEquals(3, cache.get("three", () -> 999));
300 		assertEquals(3, cache.getCacheHits());
301 	}
302 
303 	@Test void a15_weakMode_clear() {
304 		var cache = Cache.of(String.class, Integer.class)
305 			.cacheMode(WEAK)
306 			.build();
307 
308 		cache.get("one", () -> 1);
309 		cache.get("two", () -> 2);
310 		assertSize(2, cache);
311 
312 		cache.clear();
313 		assertEmpty(cache);
314 	}
315 
316 	@Test void a16_weakMode_maxSize() {
317 		var cache = Cache.of(String.class, Integer.class)
318 			.cacheMode(WEAK)
319 			.maxSize(3)
320 			.build();
321 
322 		cache.get("one", () -> 1);
323 		cache.get("two", () -> 2);
324 		cache.get("three", () -> 3);
325 		assertSize(3, cache);
326 
327 		// 4th item doesn't trigger eviction yet
328 		cache.get("four", () -> 4);
329 		assertSize(4, cache);
330 
331 		// 5th item triggers eviction
332 		cache.get("five", () -> 5);
333 		assertSize(1, cache);
334 	}
335 
336 	@Test void a16b_weakMethod_basicCaching() {
337 		// Test the weak() convenience method
338 		var cache = Cache.of(String.class, String.class)
339 			.weak()
340 			.build();
341 		var callCount = new AtomicInteger();
342 
343 		// First call - cache miss
344 		var result1 = cache.get("key1", () -> {
345 			callCount.incrementAndGet();
346 			return "value1";
347 		});
348 
349 		// Second call - cache hit
350 		var result2 = cache.get("key1", () -> {
351 			callCount.incrementAndGet();
352 			return "should not be called";
353 		});
354 
355 		assertEquals("value1", result1);
356 		assertEquals("value1", result2);
357 		assertSame(result1, result2);
358 		assertEquals(1, callCount.get()); // Supplier only called once
359 		assertSize(1, cache);
360 		assertEquals(1, cache.getCacheHits());
361 	}
362 
363 	@Test void a16c_weakMethod_chaining() {
364 		// Test that weak() can be chained with other builder methods
365 		var cache = Cache.of(String.class, Integer.class)
366 			.weak()
367 			.maxSize(100)
368 			.supplier(k -> k.length())
369 			.build();
370 
371 		var result = cache.get("hello");
372 		assertEquals(5, result);
373 		assertSize(1, cache);
374 	}
375 
376 	//====================================================================================================
377 	// Builder configuration
378 	//====================================================================================================
379 
380 	@Test void a17_builder_defaults() {
381 		var cache = Cache.of(String.class, String.class).build();
382 
383 		// Should work with defaults
384 		cache.get("key1", () -> "value1");
385 		assertSize(1, cache);
386 	}
387 
388 	@Test void a18_builder_chaining() {
389 		var cache = Cache.of(String.class, String.class)
390 			.maxSize(100)
391 			.cacheMode(NONE)
392 			.build();
393 
394 		// Disabled takes precedence
395 		cache.get("key1", () -> "value1");
396 		assertEmpty(cache);
397 	}
398 
399 	//====================================================================================================
400 	// Cache hits statistics
401 	//====================================================================================================
402 
403 	@Test void a19_cacheHits_countsCorrectly() {
404 		var cache = Cache.of(String.class, Integer.class).build();
405 
406 		assertEquals(0, cache.getCacheHits());
407 
408 		cache.get("one", () -> 1); // Miss
409 		assertEquals(0, cache.getCacheHits());
410 
411 		cache.get("one", () -> 999); // Hit
412 		assertEquals(1, cache.getCacheHits());
413 
414 		cache.get("two", () -> 2); // Miss
415 		assertEquals(1, cache.getCacheHits());
416 
417 		cache.get("one", () -> 999); // Hit
418 		cache.get("two", () -> 999); // Hit
419 		assertEquals(3, cache.getCacheHits());
420 	}
421 
422 	@Test void a20_cacheHits_persistsAfterClear() {
423 		var cache = Cache.of(String.class, Integer.class).build();
424 
425 		cache.get("one", () -> 1);
426 		cache.get("one", () -> 999); // Hit
427 		assertEquals(1, cache.getCacheHits());
428 
429 		cache.clear();
430 		assertEquals(1, cache.getCacheHits()); // Still 1
431 
432 		cache.get("one", () -> 1); // Miss (recomputed)
433 		cache.get("one", () -> 999); // Hit
434 		assertEquals(2, cache.getCacheHits()); // Incremented
435 	}
436 
437 	//====================================================================================================
438 	// Thread safety and concurrency
439 	//====================================================================================================
440 
441 	@Test void a21_concurrentAccess() throws Exception {
442 		var cache = Cache.of(Integer.class, String.class).build();
443 		var executor = Executors.newFixedThreadPool(10);
444 		var callCount = new AtomicInteger();
445 
446 		// Submit 100 tasks that all try to cache the same key
447 		var futures = new CompletableFuture[100];
448 		for (var i = 0; i < 100; i++) {
449 			futures[i] = CompletableFuture.runAsync(() -> {
450 				cache.get(1, () -> {
451 					callCount.incrementAndGet();
452 					return "value";
453 				});
454 			}, executor);
455 		}
456 
457 		// Wait for all tasks to complete
458 		CompletableFuture.allOf(futures).get(5, TimeUnit.SECONDS);
459 
460 		// Supplier might be called multiple times due to putIfAbsent semantics,
461 		// but should be much less than 100
462 		assertTrue(callCount.get() < 10, "Supplier called " + callCount.get() + " times");
463 		assertSize(1, cache);
464 
465 		executor.shutdown();
466 	}
467 
468 	@Test void a22_concurrentDifferentKeys() throws Exception {
469 		var cache = Cache.of(Integer.class, String.class).build();
470 		var executor = Executors.newFixedThreadPool(10);
471 
472 		// Submit tasks for different keys
473 		var futures = new CompletableFuture[10];
474 		for (var i = 0; i < 10; i++) {
475 			final int key = i;
476 			futures[i] = CompletableFuture.runAsync(() -> {
477 				cache.get(key, () -> "value" + key);
478 			}, executor);
479 		}
480 
481 		// Wait for all tasks to complete
482 		CompletableFuture.allOf(futures).get(5, TimeUnit.SECONDS);
483 
484 		assertSize(10, cache);
485 
486 		executor.shutdown();
487 	}
488 
489 	//====================================================================================================
490 	// Different key/value types
491 	//====================================================================================================
492 
493 	@Test void a23_integerKeys() {
494 		var cache = Cache.of(Integer.class, String.class).build();
495 
496 		cache.get(1, () -> "one");
497 		cache.get(2, () -> "two");
498 
499 		assertEquals("one", cache.get(1, () -> "should not call"));
500 		assertSize(2, cache);
501 		assertEquals(1, cache.getCacheHits());
502 	}
503 
504 	@Test void a24_classKeys() {
505 		var cache = Cache.of(Class.class, String.class).build();
506 
507 		cache.get(String.class, () -> "String");
508 		cache.get(Integer.class, () -> "Integer");
509 
510 		assertEquals("String", cache.get(String.class, () -> "should not call"));
511 		assertSize(2, cache);
512 	}
513 
514 	//====================================================================================================
515 	// Edge cases
516 	//====================================================================================================
517 
518 	@Test void a25_sameKeyDifferentValues_returnsFirstCached() {
519 		var cache = Cache.of(String.class, String.class).build();
520 
521 		var result1 = cache.get("key", () -> "first");
522 		var result2 = cache.get("key", () -> "second");
523 
524 		assertEquals("first", result1);
525 		assertEquals("first", result2); // Returns cached, not "second"
526 		assertSize(1, cache);
527 	}
528 
529 	@Test void a26_emptyCache_operations() {
530 		var cache = Cache.of(String.class, String.class).build();
531 
532 		assertEmpty(cache);
533 		assertEquals(0, cache.getCacheHits());
534 		cache.clear(); // Should not throw on empty cache
535 		assertEmpty(cache);
536 	}
537 
538 	@Test void a27_maxSize_exactBoundary() {
539 		var cache = Cache.of(Integer.class, String.class)
540 			.maxSize(3)
541 			.build();
542 
543 		cache.get(1, () -> "one");
544 		cache.get(2, () -> "two");
545 		cache.get(3, () -> "three");
546 
547 		assertSize(3, cache);
548 
549 		// Accessing existing keys shouldn't trigger eviction
550 		cache.get(1, () -> "should not call");
551 		cache.get(2, () -> "should not call");
552 		assertSize(3, cache);
553 		assertEquals(2, cache.getCacheHits());
554 	}
555 
556 	//====================================================================================================
557 	// logOnExit configuration
558 	//====================================================================================================
559 
560 	@Test void a28_logOnExit_withStringId() {
561 		// Test that logOnExit(String) enables logging and sets the id
562 		var cache = Cache.of(String.class, String.class)
563 			.logOnExit("TestCache")
564 			.build();
565 
566 		// Use the cache to generate some statistics
567 		cache.get("key1", () -> "value1");
568 		cache.get("key1", () -> "should not be called"); // Cache hit
569 		cache.get("key2", () -> "value2");
570 
571 		// Verify cache works normally
572 		assertSize(2, cache);
573 		assertEquals(1, cache.getCacheHits());
574 
575 		// Note: We can't easily test that the shutdown hook was actually registered
576 		// without triggering JVM shutdown, but we can verify the cache was created
577 		// and works correctly with logOnExit enabled
578 	}
579 
580 	@Test void a29_logOnExit_withBooleanTrue() {
581 		// Test that logOnExit(boolean, String) with true enables logging
582 		var cache = Cache.of(String.class, Integer.class)
583 			.logOnExit(true, "MyCache")
584 			.build();
585 
586 		cache.get("one", () -> 1);
587 		cache.get("one", () -> 999); // Cache hit
588 
589 		assertSize(1, cache);
590 		assertEquals(1, cache.getCacheHits());
591 	}
592 
593 	@Test void a30_logOnExit_withBooleanFalse() {
594 		// Test that logOnExit(boolean, String) with false disables logging
595 		var cache = Cache.of(String.class, Integer.class)
596 			.logOnExit(false, "DisabledCache")
597 			.build();
598 
599 		cache.get("one", () -> 1);
600 		cache.get("two", () -> 2);
601 
602 		assertSize(2, cache);
603 		assertEquals(0, cache.getCacheHits());
604 	}
605 
606 	@Test void a31_logOnExit_chaining() {
607 		// Test that logOnExit can be chained with other builder methods
608 		var cache = Cache.of(String.class, String.class)
609 			.maxSize(100)
610 			.logOnExit("ChainedCache")
611 			.supplier(k -> "value-" + k)
612 			.build();
613 
614 		var result = cache.get("test");
615 		assertEquals("value-test", result);
616 		assertSize(1, cache);
617 	}
618 
619 	@Test void a32_logOnExit_multipleCalls_lastWins() {
620 		// Test that calling logOnExit multiple times updates the id
621 		var cache = Cache.of(String.class, String.class)
622 			.logOnExit("FirstId")
623 			.logOnExit("SecondId")
624 			.logOnExit(true, "FinalId")
625 			.build();
626 
627 		cache.get("key", () -> "value");
628 		assertSize(1, cache);
629 		// The final id should be "FinalId" (though we can't easily verify this without
630 		// checking the shutdown hook, the cache should still work correctly)
631 	}
632 
633 	//====================================================================================================
634 	// put() method
635 	//====================================================================================================
636 
637 	@Test void a33_put_directInsertion() {
638 		var cache = Cache.of(String.class, String.class).build();
639 
640 		// Put a value directly
641 		var previous = cache.put("key1", "value1");
642 		assertNull(previous, "Should return null for new key");
643 		assertEquals("value1", cache.get("key1", () -> "should not be called"));
644 		assertSize(1, cache);
645 	}
646 
647 	@Test void a34_put_overwritesExisting() {
648 		var cache = Cache.of(String.class, String.class).build();
649 
650 		cache.put("key1", "value1");
651 		var previous = cache.put("key1", "value2");
652 		assertEquals("value1", previous, "Should return previous value");
653 		assertEquals("value2", cache.get("key1", () -> "should not be called"));
654 		assertSize(1, cache);
655 	}
656 
657 	@Test void a35_put_withNullValue() {
658 		var cache = Cache.of(String.class, String.class).build();
659 
660 		cache.put("key1", "value1");
661 		var previous = cache.put("key1", null);
662 		assertEquals("value1", previous);
663 		// Null values are not cached, so key should be removed
664 		assertFalse(cache.containsKey("key1"), "Key should be removed when null value is put");
665 		// Null values are not cached, so get() will call supplier
666 		var callCount = new AtomicInteger();
667 		var result = cache.get("key1", () -> {
668 			callCount.incrementAndGet();
669 			return "supplied";
670 		});
671 		assertEquals("supplied", result);
672 		assertEquals(1, callCount.get());
673 		// After get() with non-null supplier, key is now in cache again
674 		assertTrue(cache.containsKey("key1"), "Key should be in cache after get() with non-null supplier");
675 	}
676 
677 	@Test void a35b_put_withNullValue_newKey() {
678 		var cache = Cache.of(String.class, String.class).build();
679 		// Putting null for a new key should return null and not add anything
680 		var previous = cache.put("key1", null);
681 		assertNull(previous);
682 		assertFalse(cache.containsKey("key1"));
683 		assertTrue(cache.isEmpty());
684 	}
685 
686 	//====================================================================================================
687 	// isEmpty() method
688 	//====================================================================================================
689 
690 	@Test void a36_isEmpty_newCache() {
691 		var cache = Cache.of(String.class, String.class).build();
692 		assertTrue(cache.isEmpty());
693 	}
694 
695 	@Test void a37_isEmpty_afterPut() {
696 		var cache = Cache.of(String.class, String.class).build();
697 		cache.put("key1", "value1");
698 		assertFalse(cache.isEmpty());
699 	}
700 
701 	@Test void a38_isEmpty_afterGet() {
702 		var cache = Cache.of(String.class, String.class).build();
703 		cache.get("key1", () -> "value1");
704 		assertFalse(cache.isEmpty());
705 	}
706 
707 	@Test void a39_isEmpty_afterClear() {
708 		var cache = Cache.of(String.class, String.class).build();
709 		cache.put("key1", "value1");
710 		cache.put("key2", "value2");
711 		assertFalse(cache.isEmpty());
712 		cache.clear();
713 		assertTrue(cache.isEmpty());
714 	}
715 
716 	@Test void a40_isEmpty_disabledCache() {
717 		var cache = Cache.of(String.class, String.class)
718 			.cacheMode(NONE)
719 			.build();
720 		cache.get("key1", () -> "value1");
721 		assertTrue(cache.isEmpty(), "Disabled cache should always be empty");
722 	}
723 
724 	//====================================================================================================
725 	// containsKey() method
726 	//====================================================================================================
727 
728 	@Test void a41_containsKey_notPresent() {
729 		var cache = Cache.of(String.class, String.class).build();
730 		assertFalse(cache.containsKey("key1"));
731 	}
732 
733 	@Test void a42_containsKey_afterPut() {
734 		var cache = Cache.of(String.class, String.class).build();
735 		cache.put("key1", "value1");
736 		assertTrue(cache.containsKey("key1"));
737 		assertFalse(cache.containsKey("key2"));
738 	}
739 
740 	@Test void a43_containsKey_afterGet() {
741 		var cache = Cache.of(String.class, String.class).build();
742 		cache.get("key1", () -> "value1");
743 		assertTrue(cache.containsKey("key1"));
744 	}
745 
746 	@Test void a44_containsKey_afterClear() {
747 		var cache = Cache.of(String.class, String.class).build();
748 		cache.put("key1", "value1");
749 		assertTrue(cache.containsKey("key1"));
750 		cache.clear();
751 		assertFalse(cache.containsKey("key1"));
752 	}
753 
754 	@Test void a45_containsKey_nullKey() {
755 		var cache = Cache.of(String.class, String.class).build();
756 		// Null keys are now cached, so containsKey should return true after get()
757 		cache.get(null, () -> "value");
758 		assertTrue(cache.containsKey(null));
759 	}
760 
761 	//====================================================================================================
762 	// remove() method
763 	//====================================================================================================
764 
765 	@Test void a46_remove_existingKey() {
766 		var cache = Cache.of(String.class, String.class).build();
767 		cache.put("key1", "value1");
768 		var removed = cache.remove("key1");
769 		assertEquals("value1", removed);
770 		assertFalse(cache.containsKey("key1"));
771 		assertTrue(cache.isEmpty());
772 	}
773 
774 	@Test void a47_remove_nonExistentKey() {
775 		var cache = Cache.of(String.class, String.class).build();
776 		var removed = cache.remove("key1");
777 		assertNull(removed);
778 	}
779 
780 	@Test void a48_remove_afterGet() {
781 		var cache = Cache.of(String.class, String.class).build();
782 		cache.get("key1", () -> "value1");
783 		var removed = cache.remove("key1");
784 		assertEquals("value1", removed);
785 		assertFalse(cache.containsKey("key1"));
786 	}
787 
788 	@Test void a49_remove_nullKey() {
789 		var cache = Cache.of(String.class, String.class).build();
790 		cache.put(null, "value1");
791 		var removed = cache.remove(null);
792 		assertEquals("value1", removed);
793 		assertFalse(cache.containsKey(null));
794 	}
795 
796 	//====================================================================================================
797 	// containsValue() method
798 	//====================================================================================================
799 
800 	@Test void a50_containsValue_present() {
801 		var cache = Cache.of(String.class, String.class).build();
802 		cache.put("key1", "value1");
803 		cache.put("key2", "value2");
804 		assertTrue(cache.containsValue("value1"));
805 		assertTrue(cache.containsValue("value2"));
806 		assertFalse(cache.containsValue("value3"));
807 	}
808 
809 	@Test void a51_containsValue_notPresent() {
810 		var cache = Cache.of(String.class, String.class).build();
811 		assertFalse(cache.containsValue("value1"));
812 	}
813 
814 	@Test void a52_containsValue_afterRemove() {
815 		var cache = Cache.of(String.class, String.class).build();
816 		cache.put("key1", "value1");
817 		assertTrue(cache.containsValue("value1"));
818 		cache.remove("key1");
819 		assertFalse(cache.containsValue("value1"));
820 	}
821 
822 	@Test void a53_containsValue_afterClear() {
823 		var cache = Cache.of(String.class, String.class).build();
824 		cache.put("key1", "value1");
825 		cache.put("key2", "value2");
826 		assertTrue(cache.containsValue("value1"));
827 		cache.clear();
828 		assertFalse(cache.containsValue("value1"));
829 		assertFalse(cache.containsValue("value2"));
830 	}
831 
832 	@Test void a54_containsValue_nullValue() {
833 		var cache = Cache.of(String.class, String.class).build();
834 		// Null values can't be cached, so containsValue(null) should return false
835 		cache.get("key1", () -> null);
836 		assertFalse(cache.containsValue(null));
837 	}
838 
839 	//====================================================================================================
840 	// Array key support
841 	//====================================================================================================
842 
843 	@Test void a46_arrayKeys_contentBasedEquality() {
844 		var cache = Cache.of(String[].class, String.class).build();
845 
846 		var key1 = new String[]{"a", "b", "c"};
847 		var key2 = new String[]{"a", "b", "c"}; // Same content, different instance
848 
849 		cache.get(key1, () -> "value1");
850 		// Should be a cache hit even though it's a different array instance
851 		var result = cache.get(key2, () -> "should not be called");
852 		assertEquals("value1", result);
853 		assertEquals(1, cache.getCacheHits());
854 		assertSize(1, cache);
855 	}
856 
857 	@Test void a47_arrayKeys_differentContent() {
858 		var cache = Cache.of(String[].class, String.class).build();
859 
860 		var key1 = new String[]{"a", "b", "c"};
861 		var key2 = new String[]{"a", "b", "d"}; // Different content
862 
863 		cache.get(key1, () -> "value1");
864 		var result = cache.get(key2, () -> "value2");
865 		assertEquals("value2", result);
866 		assertSize(2, cache);
867 	}
868 
869 	@Test void a48_arrayKeys_put() {
870 		var cache = Cache.of(String[].class, String.class).build();
871 
872 		var key1 = new String[]{"a", "b"};
873 		var key2 = new String[]{"a", "b"}; // Same content
874 
875 		cache.put(key1, "value1");
876 		assertTrue(cache.containsKey(key2));
877 		assertEquals("value1", cache.get(key2, () -> "should not be called"));
878 	}
879 
880 	//====================================================================================================
881 	// Null value handling
882 	//====================================================================================================
883 
884 	@Test void a49_nullValue_notCached() {
885 		var cache = Cache.of(String.class, String.class).build();
886 		var callCount = new AtomicInteger();
887 
888 		// Supplier returns null - should not be cached
889 		var result1 = cache.get("key1", () -> {
890 			callCount.incrementAndGet();
891 			return null;
892 		});
893 		assertNull(result1);
894 		assertEquals(1, callCount.get());
895 
896 		// Second call should invoke supplier again (not cached)
897 		var result2 = cache.get("key1", () -> {
898 			callCount.incrementAndGet();
899 			return null;
900 		});
901 		assertNull(result2);
902 		assertEquals(2, callCount.get());
903 		assertTrue(cache.isEmpty(), "Null values should not be cached");
904 	}
905 
906 	@Test void a50_nullValue_afterPut() {
907 		var cache = Cache.of(String.class, String.class).build();
908 		cache.put("key1", "value1");
909 		cache.put("key1", null);
910 		// After putting null, the key should be removed
911 		var callCount = new AtomicInteger();
912 		var result = cache.get("key1", () -> {
913 			callCount.incrementAndGet();
914 			return "supplied";
915 		});
916 		assertEquals("supplied", result);
917 		assertEquals(1, callCount.get());
918 	}
919 
920 	//====================================================================================================
921 	// create() static method
922 	//====================================================================================================
923 
924 	@Test void a51_create_basic() {
925 		var cache = Cache.<String, String>create()
926 			.supplier(k -> "value-" + k)
927 			.build();
928 
929 		var result = cache.get("test");
930 		assertEquals("value-test", result);
931 		assertSize(1, cache);
932 	}
933 
934 	@Test void a52_create_withConfiguration() {
935 		var cache = Cache.<String, Integer>create()
936 			.maxSize(50)
937 			.cacheMode(WEAK)
938 			.supplier(k -> k.length())
939 			.build();
940 
941 		var result = cache.get("hello");
942 		assertEquals(5, result);
943 		assertSize(1, cache);
944 	}
945 
946 	//====================================================================================================
947 	// disableCaching() builder method
948 	//====================================================================================================
949 
950 	@Test void a53_disableCaching() {
951 		var cache = Cache.of(String.class, String.class)
952 			.cacheMode(NONE)
953 			.build();
954 
955 		var callCount = new AtomicInteger();
956 		cache.get("key1", () -> {
957 			callCount.incrementAndGet();
958 			return "value1";
959 		});
960 		cache.get("key1", () -> {
961 			callCount.incrementAndGet();
962 			return "value1";
963 		});
964 
965 		assertEquals(2, callCount.get(), "Supplier should be called every time when disabled");
966 		assertTrue(cache.isEmpty());
967 		assertEquals(0, cache.getCacheHits());
968 	}
969 
970 	//====================================================================================================
971 	// Thread-local cache mode
972 	//====================================================================================================
973 
974 	@Test void a54_threadLocal_basicCaching() throws Exception {
975 		var cache = Cache.of(String.class, String.class)
976 			.threadLocal()
977 			.build();
978 		var callCount = new AtomicInteger();
979 
980 		// First call - cache miss
981 		var result1 = cache.get("key1", () -> {
982 			callCount.incrementAndGet();
983 			return "value1";
984 		});
985 
986 		// Second call - cache hit
987 		var result2 = cache.get("key1", () -> {
988 			callCount.incrementAndGet();
989 			return "should not be called";
990 		});
991 
992 		assertEquals("value1", result1);
993 		assertEquals("value1", result2);
994 		assertSame(result1, result2);
995 		assertEquals(1, callCount.get()); // Supplier only called once
996 		assertSize(1, cache);
997 		assertEquals(1, cache.getCacheHits());
998 	}
999 
1000 	@Test void a55_threadLocal_eachThreadHasOwnCache() throws Exception {
1001 		var cache = Cache.of(String.class, String.class)
1002 			.threadLocal()
1003 			.build();
1004 		var executor = Executors.newFixedThreadPool(2);
1005 		var threadValues = new ConcurrentHashMap<Thread, String>();
1006 
1007 		// Each thread caches "key1" with its own value
1008 		var future1 = CompletableFuture.runAsync(() -> {
1009 			var value = cache.get("key1", () -> "thread1-value");
1010 			threadValues.put(Thread.currentThread(), value);
1011 		}, executor);
1012 
1013 		var future2 = CompletableFuture.runAsync(() -> {
1014 			var value = cache.get("key1", () -> "thread2-value");
1015 			threadValues.put(Thread.currentThread(), value);
1016 		}, executor);
1017 
1018 		CompletableFuture.allOf(future1, future2).get(5, TimeUnit.SECONDS);
1019 
1020 		// Verify both threads cached their own values
1021 		assertEquals(2, threadValues.size());
1022 		assertTrue(threadValues.containsValue("thread1-value"));
1023 		assertTrue(threadValues.containsValue("thread2-value"));
1024 
1025 		// Verify each thread's cache is independent - same thread should get same cached value
1026 		var threadValues2 = new ConcurrentHashMap<Thread, String>();
1027 		var threads = new ArrayList<>(threadValues.keySet());
1028 
1029 		future1 = CompletableFuture.runAsync(() -> {
1030 			var value = cache.get("key1", () -> "should-not-be-called");
1031 			threadValues2.put(Thread.currentThread(), value);
1032 		}, executor);
1033 
1034 		future2 = CompletableFuture.runAsync(() -> {
1035 			var value = cache.get("key1", () -> "should-not-be-called");
1036 			threadValues2.put(Thread.currentThread(), value);
1037 		}, executor);
1038 
1039 		CompletableFuture.allOf(future1, future2).get(5, TimeUnit.SECONDS);
1040 
1041 		// Each thread should get its own cached value (same as what it cached before)
1042 		for (var thread : threads) {
1043 			if (threadValues2.containsKey(thread)) {
1044 				assertEquals(threadValues.get(thread), threadValues2.get(thread),
1045 					"Thread " + thread + " should get its own cached value");
1046 			}
1047 		}
1048 
1049 		executor.shutdown();
1050 	}
1051 
1052 	@Test void a56_threadLocal_multipleKeys() {
1053 		var cache = Cache.of(String.class, Integer.class)
1054 			.threadLocal()
1055 			.build();
1056 
1057 		cache.get("one", () -> 1);
1058 		cache.get("two", () -> 2);
1059 		cache.get("three", () -> 3);
1060 
1061 		assertSize(3, cache);
1062 		assertEquals(0, cache.getCacheHits());
1063 
1064 		// Verify all cached
1065 		assertEquals(1, cache.get("one", () -> 999));
1066 		assertEquals(2, cache.get("two", () -> 999));
1067 		assertEquals(3, cache.get("three", () -> 999));
1068 		assertEquals(3, cache.getCacheHits());
1069 	}
1070 
1071 	@Test void a57_threadLocal_clear() {
1072 		var cache = Cache.of(String.class, Integer.class)
1073 			.threadLocal()
1074 			.build();
1075 
1076 		cache.get("one", () -> 1);
1077 		cache.get("two", () -> 2);
1078 		assertSize(2, cache);
1079 
1080 		cache.clear();
1081 		assertEmpty(cache);
1082 	}
1083 
1084 	@Test void a58_threadLocal_maxSize() {
1085 		var cache = Cache.of(String.class, Integer.class)
1086 			.threadLocal()
1087 			.maxSize(3)
1088 			.build();
1089 
1090 		cache.get("one", () -> 1);
1091 		cache.get("two", () -> 2);
1092 		cache.get("three", () -> 3);
1093 		assertSize(3, cache);
1094 
1095 		// 4th item doesn't trigger eviction yet
1096 		cache.get("four", () -> 4);
1097 		assertSize(4, cache);
1098 
1099 		// 5th item triggers eviction
1100 		cache.get("five", () -> 5);
1101 		assertSize(1, cache);
1102 	}
1103 
1104 	@Test void a59_threadLocal_cacheHits() {
1105 		var cache = Cache.of(String.class, Integer.class)
1106 			.threadLocal()
1107 			.build();
1108 
1109 		assertEquals(0, cache.getCacheHits());
1110 
1111 		cache.get("one", () -> 1); // Miss
1112 		assertEquals(0, cache.getCacheHits());
1113 
1114 		cache.get("one", () -> 999); // Hit
1115 		assertEquals(1, cache.getCacheHits());
1116 
1117 		cache.get("two", () -> 2); // Miss
1118 		assertEquals(1, cache.getCacheHits());
1119 
1120 		cache.get("one", () -> 999); // Hit
1121 		cache.get("two", () -> 999); // Hit
1122 		assertEquals(3, cache.getCacheHits());
1123 	}
1124 
1125 	//====================================================================================================
1126 	// Thread-local + weak mode combination
1127 	//====================================================================================================
1128 
1129 	@Test void a60_threadLocal_weakMode_basicCaching() {
1130 		var cache = Cache.of(String.class, String.class)
1131 			.threadLocal()
1132 			.cacheMode(WEAK)
1133 			.build();
1134 		var callCount = new AtomicInteger();
1135 
1136 		// First call - cache miss
1137 		var result1 = cache.get("key1", () -> {
1138 			callCount.incrementAndGet();
1139 			return "value1";
1140 		});
1141 
1142 		// Second call - cache hit
1143 		var result2 = cache.get("key1", () -> {
1144 			callCount.incrementAndGet();
1145 			return "should not be called";
1146 		});
1147 
1148 		assertEquals("value1", result1);
1149 		assertEquals("value1", result2);
1150 		assertSame(result1, result2);
1151 		assertEquals(1, callCount.get()); // Supplier only called once
1152 		assertSize(1, cache);
1153 		assertEquals(1, cache.getCacheHits());
1154 	}
1155 
1156 	@Test void a61_threadLocal_weakMode_eachThreadHasOwnCache() throws Exception {
1157 		var cache = Cache.of(String.class, String.class)
1158 			.threadLocal()
1159 			.cacheMode(WEAK)
1160 			.build();
1161 		var executor = Executors.newFixedThreadPool(2);
1162 		var threadValues = new ConcurrentHashMap<Thread, String>();
1163 
1164 		// Each thread caches "key1" with its own value
1165 		var future1 = CompletableFuture.runAsync(() -> {
1166 			var value = cache.get("key1", () -> "thread1-value");
1167 			threadValues.put(Thread.currentThread(), value);
1168 		}, executor);
1169 
1170 		var future2 = CompletableFuture.runAsync(() -> {
1171 			var value = cache.get("key1", () -> "thread2-value");
1172 			threadValues.put(Thread.currentThread(), value);
1173 		}, executor);
1174 
1175 		CompletableFuture.allOf(future1, future2).get(5, TimeUnit.SECONDS);
1176 
1177 		// Verify both threads cached their own values
1178 		assertEquals(2, threadValues.size());
1179 		assertTrue(threadValues.containsValue("thread1-value"));
1180 		assertTrue(threadValues.containsValue("thread2-value"));
1181 
1182 		executor.shutdown();
1183 	}
1184 
1185 	@Test void a62_threadLocal_weakMode_clear() {
1186 		var cache = Cache.of(String.class, Integer.class)
1187 			.threadLocal()
1188 			.cacheMode(WEAK)
1189 			.build();
1190 
1191 		cache.get("one", () -> 1);
1192 		cache.get("two", () -> 2);
1193 		assertSize(2, cache);
1194 
1195 		cache.clear();
1196 		assertEmpty(cache);
1197 	}
1198 
1199 	@Test void a63_threadLocal_weakMode_maxSize() {
1200 		var cache = Cache.of(String.class, Integer.class)
1201 			.threadLocal()
1202 			.cacheMode(WEAK)
1203 			.maxSize(3)
1204 			.build();
1205 
1206 		cache.get("one", () -> 1);
1207 		cache.get("two", () -> 2);
1208 		cache.get("three", () -> 3);
1209 		assertSize(3, cache);
1210 
1211 		// 4th item doesn't trigger eviction yet
1212 		cache.get("four", () -> 4);
1213 		assertSize(4, cache);
1214 
1215 		// 5th item triggers eviction
1216 		cache.get("five", () -> 5);
1217 		assertSize(1, cache);
1218 	}
1219 }
1220