11"""Unit tests for timeline.py module."""
2- import pytest
3- from unittest .mock import Mock , patch , AsyncMock , MagicMock
2+
43import datetime
4+ import os
5+ import uuid
56from dataclasses import dataclass
7+ from unittest .mock import AsyncMock , Mock
8+
9+ import aiosqlite
10+ import pytest
11+
12+ from custom_components .llmvision .timeline import Timeline
13+ from homeassistant .util import dt as dt_util
614
715
816@dataclass
@@ -30,19 +38,58 @@ def mock_hass(self):
3038 hass .config .path = Mock (return_value = "/mock/path" )
3139 hass .loop = Mock ()
3240 hass .loop .run_in_executor = AsyncMock ()
41+ hass .async_add_executor_job = AsyncMock ()
42+ hass .async_create_task = lambda coro : None
3343 return hass
3444
3545 @pytest .fixture
3646 def mock_config_entry (self ):
3747 """Create a mock config entry."""
3848 entry = Mock ()
3949 entry .entry_id = "test_entry"
40- entry .data = {
41- "provider" : "Settings" ,
42- "retention_time" : 7
43- }
50+ entry .data = {"provider" : "Settings" , "retention_time" : 7 }
51+ entry .options = {}
4452 return entry
4553
54+ @pytest .fixture
55+ def timeline_factory (self , tmp_path , monkeypatch ):
56+ """Factory that builds isolated Timeline instances for retention tests."""
57+
58+ base_path = tmp_path / "config"
59+ base_path .mkdir ()
60+
61+ real_makedirs = os .makedirs
62+
63+ def safe_makedirs (path , exist_ok = False ):
64+ if str (path ).startswith ("/media/llmvision" ):
65+ return
66+ return real_makedirs (path , exist_ok = exist_ok )
67+
68+ monkeypatch .setattr (
69+ "custom_components.llmvision.timeline.os.makedirs" , safe_makedirs
70+ )
71+
72+ def _build (retention = 2 , options = None ):
73+ hass = Mock ()
74+ hass .data = {}
75+ hass .config = Mock ()
76+ hass .config .path = lambda * parts : str (base_path .joinpath (* parts ))
77+ hass .loop = Mock ()
78+ hass .loop .run_in_executor = AsyncMock ()
79+ hass .async_add_executor_job = AsyncMock ()
80+ hass .async_create_task = lambda coro : None
81+
82+ entry = Mock ()
83+ entry .entry_id = "timeline-entry"
84+ entry .data = {"provider" : "Settings" , "retention_time" : retention }
85+ entry .options = options or {}
86+
87+ timeline = Timeline (hass , entry )
88+ timeline ._migrating = False
89+ return timeline
90+
91+ return _build
92+
4693 def test_retention_time_from_config (self , mock_hass , mock_config_entry ):
4794 """Test retention time is read from config."""
4895 assert mock_config_entry .data ["retention_time" ] == 7
@@ -56,7 +103,87 @@ def test_mock_event_creation(self):
56103 end = datetime .datetime .now (),
57104 description = "Test desc"
58105 )
59-
106+
60107 assert event .uid == "test-uid"
61108 assert event .title == "Test"
62109 assert event .key_frame == ""
110+
111+ async def _insert_rows (
112+ self ,
113+ db_path : str ,
114+ rows : list [tuple [str , str , str , str , str , str , str , str , str ]],
115+ ):
116+ async with aiosqlite .connect (db_path ) as db :
117+ for row in rows :
118+ await db .execute (
119+ """
120+ INSERT INTO events (
121+ uid, title, start, end, description,
122+ key_frame, camera_name, category, label
123+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
124+ """ ,
125+ row ,
126+ )
127+ await db .commit ()
128+
129+ def _build_row (
130+ self , title : str , age_days : float
131+ ) -> tuple [str , str , str , str , str , str , str , str , str ]:
132+ base = datetime .datetime .now (datetime .timezone .utc ) - datetime .timedelta (
133+ days = age_days
134+ )
135+ start = dt_util .as_local (base ).isoformat ()
136+ end = dt_util .as_local (base + datetime .timedelta (minutes = 1 )).isoformat ()
137+ return (
138+ str (uuid .uuid4 ()),
139+ title ,
140+ start ,
141+ end ,
142+ "" ,
143+ "" ,
144+ "" ,
145+ "" ,
146+ "" ,
147+ )
148+
149+ async def _fetch_titles (self , db_path : str ) -> list [str ]:
150+ async with aiosqlite .connect (db_path ) as db :
151+ async with db .execute ("SELECT title FROM events ORDER BY title" ) as cursor :
152+ rows = await cursor .fetchall ()
153+ return [row [0 ] for row in rows ]
154+
155+ @pytest .mark .asyncio
156+ async def test_retention_purges_old_events (self , timeline_factory ):
157+ """Events older than retention_time days are purged when loading."""
158+
159+ timeline = timeline_factory (retention = 2 )
160+ await timeline ._initialize_db ()
161+
162+ rows = [
163+ self ._build_row ("expired" , age_days = 5 ),
164+ self ._build_row ("recent" , age_days = 0.1 ),
165+ ]
166+ await self ._insert_rows (timeline ._db_path , rows )
167+
168+ await timeline .load_events ()
169+
170+ titles = await self ._fetch_titles (timeline ._db_path )
171+ assert titles == ["recent" ]
172+
173+ @pytest .mark .asyncio
174+ async def test_zero_retention_disables_purge (self , timeline_factory ):
175+ """Setting retention_time to 0 leaves old events untouched."""
176+
177+ timeline = timeline_factory (retention = 0 )
178+ await timeline ._initialize_db ()
179+
180+ rows = [
181+ self ._build_row ("expired" , age_days = 10 ),
182+ self ._build_row ("recent" , age_days = 0.5 ),
183+ ]
184+ await self ._insert_rows (timeline ._db_path , rows )
185+
186+ await timeline .load_events ()
187+
188+ titles = await self ._fetch_titles (timeline ._db_path )
189+ assert titles == ["expired" , "recent" ]
0 commit comments