Spaces:
Sleeping
Sleeping
| """A4.3 — Unit tests for IndexPersistence debounce behavior. | |
| Tests: | |
| - 100 rapid schedule_save() calls result in exactly 1 save() call. | |
| - flush() triggers immediate save without waiting for debounce timer. | |
| """ | |
| import sys | |
| import os | |
| import time | |
| import threading | |
| import unittest.mock as mock | |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) | |
| import pytest | |
| from db import StateKV | |
| from search import SearchIndex, VectorIndex | |
| from functions import IndexPersistence | |
| # Speed up debounce for tests | |
| FAST_DEBOUNCE = 0.05 | |
| def make_kv(tmp_path): | |
| return StateKV(db_path=str(tmp_path / 'test_debounce.db')) | |
| class TestDebounce: | |
| def test_100_rapid_calls_result_in_1_save(self, tmp_path): | |
| """100 rapid schedule_save() calls must fire exactly 1 save().""" | |
| kv = make_kv(tmp_path) | |
| bm25 = SearchIndex() | |
| vector = VectorIndex() | |
| persistence = IndexPersistence(kv, bm25, vector) | |
| persistence.DEBOUNCE_SECONDS = FAST_DEBOUNCE | |
| save_call_count = [0] | |
| original_save = persistence.save | |
| def counting_save(): | |
| save_call_count[0] += 1 | |
| original_save() | |
| with mock.patch.object(persistence, 'save', side_effect=counting_save): | |
| for _ in range(100): | |
| persistence.schedule_save() | |
| # Wait for the debounce timer to fire (2× debounce window is plenty) | |
| time.sleep(FAST_DEBOUNCE * 4) | |
| assert save_call_count[0] == 1, ( | |
| f"Expected exactly 1 save() call; got {save_call_count[0]}" | |
| ) | |
| def test_rapid_calls_with_dirty_bm25(self, tmp_path): | |
| """schedule_save() fires exactly once even when BM25 is dirty.""" | |
| kv = make_kv(tmp_path) | |
| bm25 = SearchIndex() | |
| vector = VectorIndex() | |
| # Add a doc so the index is dirty | |
| bm25.add({ | |
| 'id': 'obs_test1', | |
| 'sessionId': 'sess1', | |
| 'title': 'hello world', | |
| 'facts': [], | |
| 'concepts': [], | |
| 'files': [], | |
| 'type': 'other', | |
| }) | |
| persistence = IndexPersistence(kv, bm25, vector) | |
| persistence.DEBOUNCE_SECONDS = FAST_DEBOUNCE | |
| save_call_count = [0] | |
| original_save = persistence.save | |
| def counting_save(): | |
| save_call_count[0] += 1 | |
| original_save() | |
| with mock.patch.object(persistence, 'save', side_effect=counting_save): | |
| for _ in range(100): | |
| persistence.schedule_save() | |
| time.sleep(FAST_DEBOUNCE * 4) | |
| assert save_call_count[0] == 1 | |
| def test_flush_triggers_immediate_save(self, tmp_path): | |
| """flush() must call save() immediately without waiting for the debounce timer.""" | |
| kv = make_kv(tmp_path) | |
| bm25 = SearchIndex() | |
| vector = VectorIndex() | |
| persistence = IndexPersistence(kv, bm25, vector) | |
| persistence.DEBOUNCE_SECONDS = 60.0 # very long timer — flush must bypass it | |
| save_call_count = [0] | |
| original_save = persistence.save | |
| def counting_save(): | |
| save_call_count[0] += 1 | |
| original_save() | |
| with mock.patch.object(persistence, 'save', side_effect=counting_save): | |
| persistence.schedule_save() | |
| # Timer is set but hasn't fired yet (60s window) | |
| assert save_call_count[0] == 0, "save() should not have been called yet" | |
| # flush() must cancel the timer and call save() synchronously | |
| persistence.flush() | |
| assert save_call_count[0] == 1, ( | |
| f"flush() should trigger exactly 1 save(); got {save_call_count[0]}" | |
| ) | |
| def test_flush_after_no_pending_save_is_safe(self, tmp_path): | |
| """flush() with no pending timer should still call save() once.""" | |
| kv = make_kv(tmp_path) | |
| bm25 = SearchIndex() | |
| vector = VectorIndex() | |
| persistence = IndexPersistence(kv, bm25, vector) | |
| save_call_count = [0] | |
| original_save = persistence.save | |
| def counting_save(): | |
| save_call_count[0] += 1 | |
| original_save() | |
| with mock.patch.object(persistence, 'save', side_effect=counting_save): | |
| persistence.flush() | |
| assert save_call_count[0] == 1 | |
| def test_subsequent_schedule_after_fire_starts_new_timer(self, tmp_path): | |
| """Two bursts of saves separated by more than DEBOUNCE_SECONDS should fire 2 saves.""" | |
| kv = make_kv(tmp_path) | |
| bm25 = SearchIndex() | |
| vector = VectorIndex() | |
| persistence = IndexPersistence(kv, bm25, vector) | |
| persistence.DEBOUNCE_SECONDS = FAST_DEBOUNCE | |
| save_call_count = [0] | |
| original_save = persistence.save | |
| def counting_save(): | |
| save_call_count[0] += 1 | |
| original_save() | |
| with mock.patch.object(persistence, 'save', side_effect=counting_save): | |
| # First burst | |
| for _ in range(10): | |
| persistence.schedule_save() | |
| # Wait for first timer to fire | |
| time.sleep(FAST_DEBOUNCE * 4) | |
| # Second burst | |
| for _ in range(10): | |
| persistence.schedule_save() | |
| # Wait for second timer to fire | |
| time.sleep(FAST_DEBOUNCE * 4) | |
| assert save_call_count[0] == 2, ( | |
| f"Expected 2 save() calls for two separate bursts; got {save_call_count[0]}" | |
| ) | |