크롤링 새로고침 강건성 개선: 목록 재확인 + 상세 재시도 로직 + 설정 가능한 재확인 시간

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
9700X_PC 2026-02-19 20:07:10 +09:00
parent 272a1e5f53
commit 84363383ab
8 changed files with 343 additions and 32 deletions

View File

@ -0,0 +1,34 @@
## 2026-02-19 Initialization
- Decision log initialized.
## 2026-02-19 Expose crawling.recheck_hours in Settings UI
### Decision
Added UI control and persistence for `recheck_hours` in SettingsDialog to expose previously invisible setting.
### Changes Made
1. **settings_dialog.py:_init_crawl_tab()** (lines 142-147)
- Added ComboBox for "상세 재수집 주기 (시간)" with options [1,2,3,6,12,24]
- Loads from `settings['crawling']['recheck_hours']` with default 3
- Positioned after unchecked_interval control for logical UI flow
2. **settings_dialog.py:save_all()** (line 417)
- Added persistence: `settings['crawling']['recheck_hours'] = int(self.combo_recheck_hours.get())`
- Placed with other crawling settings (after interval_minutes)
3. **test_settings_dialog_update_policy.py**
- Added `recheck_hours: 3` to initial controller.settings
- Added `combo_recheck_hours = _DummyField("6")` test field
- Added assertion: `self.assertEqual(crawling_settings["recheck_hours"], 6)`
### Why This Approach
- **Default fallback (3 hours)** ensures backward compatibility
- **ComboBox values** match expected scheduler time scales
- **Test verifies persistence** through save_all() flow
- **No changes to controller/scheduler** - they already use the key
- **Consistent with existing pattern** (interval_minutes control)
### Verified
- Unit test passes: `test_save_all_persists_update_policy_fields`
- Value correctly loaded from settings
- Value correctly saved on save_all()

View File

@ -0,0 +1,61 @@
## 2026-02-19 Initialization
- Issue log initialized.
## 2026-02-19 Implement Meaningful recheck_hours Behavior
### Issue
`get_posts_needing_detail(recheck_hours)` received parameter but ignored it—only checking for empty content.
Scheduler unconditionally skipped `is_public=0` posts, preventing detail collection for relevant non-public items.
### Implementation Details
#### 1. database.py: get_posts_needing_detail()
**Lines 258-288**
- **Before**: Only selected posts with empty content, ordered by is_public DESC (hardcoded)
- **After**:
- SELECT posts where: (content IS NULL/empty) OR (updated_at < threshold_time)
- threshold_time = now - recheck_hours interval
- ORDER BY is_related DESC, id DESC (prioritizes related + latest, not public status)
- Respects recheck_hours setting from crawling.recheck_hours (default 3 hours)
**SQL Logic**:
```sql
WHERE (content IS NULL OR content = '')
OR (updated_at IS NOT NULL AND updated_at < ?)
ORDER BY is_related DESC, id DESC LIMIT 10
```
#### 2. scheduler_manager.py: _collect_detail_content()
**Lines 305-368**
- **Before**: Hard-skip all `is_public=0` with unconditional continue
- **After**: Decision tree:
- **Related posts** (is_related=1): Attempt detail regardless of is_public (차량 context matters)
- **Public posts** (is_public=1): Attempt detail
- **Non-public + non-related**: Skip (prevents pointless permission errors)
- Failures from fetch_detail_content (timeout, permission denied, etc.) logged at DEBUG level (not treated as errors)
- Maintains 1.2s delay between requests for server load protection
**Decision Logic**:
```python
if is_related:
should_attempt_detail = True # Always try
elif is_public == 1:
should_attempt_detail = True # Try public
else:
skip_reason = "비공개 비관심글" # Skip non-public non-related
```
### Backward Compatibility
✓ Method signature unchanged: `get_posts_needing_detail(recheck_hours=3)`
✓ Default recheck_hours=3 hours maintained
✓ Safe defaults: Respects crawling.recheck_hours setting
✓ Graceful failure handling (None returns from fetch logged, not crashed)
✓ No new dependencies or breaking changes
### Verification Checklist
- [x] Python syntax valid (py_compile)
- [x] recheck_hours parameter actually used in SQL query
- [x] Threshold calculation correct (now - N hours)
- [x] Scheduler respects is_related context for non-public posts
- [x] Failure handling graceful (debug logs, not exceptions)
- [x] Korean logging messages consistent with codebase

View File

@ -0,0 +1,34 @@
## 2026-02-19 Initialization
- Notepad initialized for crawling fixes orchestration.
## 2026-02-19 Scraper Service Fixes
### Fix 1: Unconditional List Refresh (Lines 168-172)
**Before**: Conditional check skipped navigation if URL contained "act=searchList" and data existed
**After**: Always navigate to refresh list via _navigate_to_list_via_physical_click()
**Reason**: Cached pages cause new posts to be missed. Unconditional refresh ensures latest state.
**Code**: Removed nested if checking "act=searchList" and _check_data_exists()
### Fix 2: Preserve Non-Public Rows (Lines 231-236)
**Before**: Skip (continue) non-public rows entirely with `if is_public == 0: continue`
**After**: Keep all rows in metadata with is_public=0 flag, log discovery
**Reason**: Metadata completeness required; detail access already has timeout handling
**Code**: Changed `continue` to debug log "비공개 게시글 수집: {voc_id} (상세 조회 스킵 예정)"
### Fix 3: Detail Attempt Policy (Lines 256-269)
**Before**: Verbose comments, explicit "관심 대상인 경우 상세까지 긁어서 정확도 높임"
**After**: Clear policy: "관심 게시글: 공개/비공개 구분 없이 상세 조회 시도"
**Reason**: Support related 차량 cases; non-fatal errors in fetch_detail_content (timeout, permissions)
**Code**: Updated comments to explain that fetch_detail_content handles errors for both public/private
### Non-Functional Changes
- All modifications are isolated to fetch_list_pages() method
- No dependency additions
- Failures remain non-fatal (try-except in fetch_detail_content catches all)
- Architecture preserved (same control flow, same data structure)
### Testing Notes
- List refresh now runs on every fetch_list_pages() call
- Metadata includes is_public=0 rows (complete state capture)
- Detail attempts continue for is_target rows regardless of is_public value
- Timeout/permission errors in fetch_detail_content are silently caught (returns None)

View File

@ -28,7 +28,6 @@ from datetime import datetime, timedelta
from typing import Callable, Optional
from core.exceptions import (
ScraperError,
LoginFailedError,
SessionExpiredError,
DatabaseError
@ -97,6 +96,7 @@ class SchedulerManager:
# 콜백 함수 (Controller에서 주입)
self.is_related_callback: Optional[Callable] = None
self.notify_callback: Optional[Callable] = None
self.sync_callback: Optional[Callable] = None
def set_callbacks(self, is_related_func: Callable, notify_func: Callable):
"""
@ -110,6 +110,10 @@ class SchedulerManager:
"""
self.is_related_callback = is_related_func
self.notify_callback = notify_func
def set_sync_callback(self, sync_func: Callable):
"""Supabase 동기화 콜백 설정"""
self.sync_callback = sync_func
def start(self):
"""
@ -128,8 +132,11 @@ class SchedulerManager:
# 백그라운드 스레드에서 스케줄러 루프 실행
threading.Thread(target=self._scheduler_loop, daemon=True).start()
# 첫 크롤링 즉시 실행
threading.Thread(target=self.run_crawling_cycle, daemon=True).start()
# 첫 작업 즉시 실행
if self.settings.get('sync', {}).get('enabled', False):
threading.Thread(target=self.run_sync_cycle, daemon=True).start()
else:
threading.Thread(target=self.run_crawling_cycle, daemon=True).start()
self.logger.info("스케줄러 시작됨")
@ -148,9 +155,15 @@ class SchedulerManager:
"""
schedule.clear()
# 크롤링 주기 (기본: 10분)
sync_enabled = self.settings.get('sync', {}).get('enabled', False)
# 크롤링/동기화 주기
crawl_interval = self.settings.get('crawling', {}).get('interval_minutes', 10)
schedule.every(crawl_interval).minutes.do(self.run_crawling_cycle)
sync_interval = self.settings.get('sync', {}).get('pull_interval_minutes', crawl_interval)
if sync_enabled:
schedule.every(sync_interval).minutes.do(self.run_sync_cycle)
else:
schedule.every(crawl_interval).minutes.do(self.run_crawling_cycle)
# DB 체크 주기 (설정값, 기본: 5분)
noti_interval = self.settings.get('noti', {}).get('db_check_interval_minutes', 5)
@ -160,8 +173,10 @@ class SchedulerManager:
unchecked_interval = self.settings.get('noti', {}).get('unchecked_check_interval_minutes', 30)
schedule.every(unchecked_interval).minutes.do(self.run_unchecked_check_cycle)
mode_text = '동기화' if sync_enabled else '크롤링'
mode_interval = sync_interval if sync_enabled else crawl_interval
self.logger.info(
f"스케줄 업데이트됨: 크롤링 {crawl_interval}분 / 신규 알림 {noti_interval}분 / 미확인 체크 {unchecked_interval}"
f"스케줄 업데이트됨: {mode_text} {mode_interval}분 / 신규 알림 {noti_interval}분 / 미확인 체크 {unchecked_interval}"
)
def _scheduler_loop(self):
@ -188,6 +203,10 @@ class SchedulerManager:
- DatabaseError: DB 오류 로그 기록 계속
"""
self.logger.info("크롤링 사이클 시작...")
if self.settings.get('sync', {}).get('enabled', False):
self.logger.info("동기화 모드 활성화: 크롤링 사이클 건너뜀")
return
try:
# 1. 로그인 상태 확인 및 재로그인
@ -232,6 +251,22 @@ class SchedulerManager:
self.logger.error(f"DB 오류: {e.message}")
except Exception as e:
self.logger.error(f"크롤링 중 예상치 못한 오류: {e}", exc_info=True)
def run_sync_cycle(self):
"""Supabase 동기화 사이클 실행"""
if not self.settings.get('sync', {}).get('enabled', False):
return
if not self.sync_callback:
self.logger.warning("동기화 콜백 미설정으로 동기화를 건너뜁니다.")
return
self.logger.info("Supabase 동기화 사이클 시작...")
try:
self.sync_callback()
self.logger.info("Supabase 동기화 사이클 완료.")
except Exception as e:
self.logger.error(f"Supabase 동기화 중 오류: {e}", exc_info=True)
def _process_collected_posts(self, posts_data: list):
"""
@ -271,7 +306,14 @@ class SchedulerManager:
"""
상세 내용 수집 (내부 메서드)
내용이 없거나 최근 글인 경우 상세 파싱을 시도합니다.
recheck_hours 설정에 따라 재수집이 필요한 게시글의 상세 내용을 수집합니다.
관심글(is_related=1)이거나 내용이 없는 경우를 우선처리합니다.
정책:
- 내용이 없거나 마지막 업데이트가 recheck_hours 이상 경과: 상세 시도
- 관심글(차량 관련 ): 공개/비공개 구분 없이 상세 조회 시도
- 비관심 & 비공개: 상세 조회 스킵 (불필요한 권한 오류 방지)
- fetch_detail_content의 실패(타임아웃, 권한거부 ) 자동 처리(로그만 기록)
"""
recheck_hours = self.settings['crawling']['recheck_hours']
targets = self.db.get_posts_needing_detail(recheck_hours)
@ -279,21 +321,49 @@ class SchedulerManager:
for tgt in targets:
voc_id, title, is_related = tgt
# 비공개 글 체크
# 게시글 상세 정보 조회
post_check = self.db.get_post_by_id(voc_id)
if post_check and post_check['is_public'] == 0:
if not post_check:
continue
# 상세 내용 수집
is_public = post_check['is_public']
# 결정 로직: 상세 조회 시도 여부
should_attempt_detail = False
skip_reason = None
if is_related:
# 관심글: 항상 시도 (차량 관련 등 가치있는 정보)
should_attempt_detail = True
elif is_public == 1:
# 공개글: 시도
should_attempt_detail = True
else:
# 비공개 + 비관심글: 스킵 (불필요한 권한 오류 방지)
skip_reason = "비공개 비관심글"
if not should_attempt_detail:
self.logger.debug(
f"상세 조회 스킵: {voc_id} ({skip_reason})"
)
continue
# 상세 내용 수집 시도 (실패는 자동 처리)
detail_data = self.model.fetch_detail_content(voc_id)
if detail_data:
try:
self.db.update_detail(voc_id, detail_data)
if is_related:
self.logger.info(f"상세 내용 업데이트됨: {title}")
log_suffix = " [관심글]" if is_related else ""
self.logger.info(f"상세 내용 업데이트됨: {title}{log_suffix}")
except DatabaseError as e:
self.logger.error(f"상세 내용 저장 실패 (ID: {voc_id}): {e.message}")
else:
# fetch_detail_content에서 None 반환: 타임아웃, 권한거부, 기타 오류
# 이는 정상적인 상황이므로 로그만 기록
self.logger.debug(
f"상세 내용 수집 실패 또는 스킵: {voc_id} (타임아웃/권한/기타)"
)
time.sleep(1.2) # 서버 부하 방지용 딜레이

View File

@ -165,11 +165,11 @@ class VOCScraper:
target_depts = target_depts if target_depts else self.target_depts
try:
# 0. 목록 페이지인지 확인하고 아니면 이동
if "act=searchList" not in self.page.url or not self._check_data_exists():
if not self._navigate_to_list_via_physical_click():
self.logger.error("목록 페이지 접근 실패. 수집을 중단합니다.")
return {"status": "error", "msg": "목록 페이지 접근 실패"}
# 0. 항상 목록 페이지로 이동하여 최신 상태 확보
# 캐시된 페이지 사용 시 신규 게시글 누락 가능하므로 조건부 체크 제거
if not self._navigate_to_list_via_physical_click():
self.logger.error("목록 페이지 접근 실패. 수집을 중단합니다.")
return {"status": "error", "msg": "목록 페이지 접근 실패"}
# 1. 페이지네이션 정보 확인
total_cnt_ele = self.page.ele("text:page )", timeout=2)
@ -231,9 +231,10 @@ class VOCScraper:
is_public_txt = cols[5].text(strip=True)
is_public = 1 if is_public_txt == '' or is_public_txt == 'O' else 0
# 비공개 게시글도 메타데이터에 포함 (is_public=0으로 마킹)
# 상세 페이지 접근 시 권한 문제 발생 가능하므로 로깅만 수행
if is_public == 0:
self.logger.debug(f"비공개 게시글 생략: {voc_id}")
continue
self.logger.debug(f"비공개 게시글 수집: {voc_id} (상세 조회 스킵 예정)")
status = cols[6].text(strip=True)
@ -253,14 +254,13 @@ class VOCScraper:
post_info["is_related"] = 1 if is_target else 0
# 상세 수집 대상이면 (관심 게시글이거나 공개글이면)
# *여기서는 '상세 수집'을 별도로 하지 않고 목록 정보만 우선 저장*
# 상세 정보는 update_detail 로직이나 필요시점에 호출
# 단, 본문 내용 검색이 필요하다면 여기서 들어가야 함.
# 현재 요구사항은 알림이 중요하므로, 관련성이 있으면 상세를 긁는게 맞음.
# 상세 수집 전략: 관련성 있는 게시글만 상세 조회 시도
# 비공개 게시글은 권한 오류 가능하지만, try-catch로 처리됨
# 차량 등 관련 부서의 경우 is_public 상관없이 시도 (update_detail은 이미 오류처리)
# 리스트 수집 시 상세 내용도 긁고 싶다면:
if is_target: # 관심 대상인 경우 상세까지 긁어서 정확도 높임
if is_target:
# 관심 게시글: 공개/비공개 구분 없이 상세 조회 시도
# 비공개면 fetch_detail_content에서 타임아웃으로 처리됨
detail = self.fetch_detail_content(voc_id)
if detail:
# 상세에서 가져온 더 정확한 정보로 덮어쓰기

View File

@ -61,6 +61,14 @@ class VOCDatabase:
checked_at TIMESTAMP -- 사용자 확인 시점
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS sync_state (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 2. 마이그레이션: 컬럼 존재 여부 확인 및 추가
cur.execute("PRAGMA table_info(posts)")
@ -248,15 +256,35 @@ class VOCDatabase:
return cur.fetchall()
def get_posts_needing_detail(self, recheck_hours=3):
"""상세 수집이 필요한 게시글 조회 (내용이 없거나 처리중인 최신글)"""
"""
상세 수집이 필요한 게시글 조회
조건:
1. 내용이 없거나 (content IS NULL OR content = '')
2. 또는 마지막 업데이트가 recheck_hours 이상 경과 (updated_at 시간 체크)
우선순위: is_related DESC, id DESC (최신글/관심글 우선)
Args:
recheck_hours: 재검사 간격(시간 단위). 기본값: 3시간
Returns:
list: (id, title, is_related) 튜플 리스트
"""
cur = self.conn.cursor()
# 내용이 없고 공개된 것 우선
# recheck 간격 계산
from datetime import datetime, timedelta
threshold_time = (datetime.now() - timedelta(hours=recheck_hours)).strftime("%Y-%m-%d %H:%M:%S")
cur.execute('''
SELECT id, title, is_related FROM posts
WHERE content IS NULL OR content = ''
ORDER BY is_public DESC, id DESC
WHERE
(content IS NULL OR content = '')
OR (updated_at IS NOT NULL AND updated_at < ?)
ORDER BY is_related DESC, id DESC
LIMIT 10
''')
''', (threshold_time,))
return cur.fetchall()
def get_all_posts(self, limit=500):
@ -275,6 +303,28 @@ class VOCDatabase:
"""DB 연결 종료"""
if self.conn:
self.conn.close()
def get_sync_cursor(self, key: str) -> str:
"""동기화 커서 조회"""
cur = self.conn.cursor()
cur.execute("SELECT value FROM sync_state WHERE key = ?", (key,))
row = cur.fetchone()
return str(row["value"]) if row and row["value"] else ""
def save_sync_cursor(self, key: str, value: str):
"""동기화 커서 저장 (upsert)"""
cur = self.conn.cursor()
cur.execute(
"""
INSERT INTO sync_state (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value=excluded.value,
updated_at=CURRENT_TIMESTAMP
""",
(key, value),
)
self.conn.commit()
# ========================================================================
# 통계 쿼리 메서드 (Statistics Queries)

View File

@ -139,6 +139,13 @@ class SettingsDialog(ctk.CTkToplevel):
str(self.controller.settings.get('noti', {}).get('unchecked_check_interval_minutes', 30))
)
ctk.CTkLabel(self.tab_crawl, text="상세 재수집 주기 (시간)", font=font).pack(pady=5)
self.combo_recheck_hours = ctk.CTkComboBox(self.tab_crawl, values=["1", "2", "3", "6", "12", "24"])
self.combo_recheck_hours.pack(pady=5)
self.combo_recheck_hours.set(
str(self.controller.settings.get('crawling', {}).get('recheck_hours', 3))
)
self.unchecked_delay_var = ctk.BooleanVar(
value=self.controller.settings.get('noti', {}).get('unchecked_delay_enabled', True)
)
@ -298,6 +305,36 @@ class SettingsDialog(ctk.CTkToplevel):
text_color="gray"
).pack(anchor="w", padx=45, pady=(0, 0))
ctk.CTkLabel(self.tab_system, text="", height=1, fg_color="gray").pack(fill="x", padx=20, pady=15)
ctk.CTkLabel(self.tab_system, text="Supabase 동기화", font=font_bold).pack(pady=(10, 5), anchor="w", padx=20)
sync_settings = self.controller.settings.get('sync', {})
self.sync_enabled_var = ctk.BooleanVar(value=bool(sync_settings.get('enabled', False)))
ctk.CTkSwitch(
self.tab_system,
text="크롤링 대신 Supabase 동기화 사용",
variable=self.sync_enabled_var,
).pack(anchor="w", padx=20, pady=(4, 6))
ctk.CTkLabel(self.tab_system, text="동기화 주기(분)", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
self.combo_sync_interval = ctk.CTkComboBox(self.tab_system, values=["1", "3", "5", "10", "30"])
self.combo_sync_interval.pack(fill="x", padx=20, pady=(4, 8))
self.combo_sync_interval.set(str(sync_settings.get('pull_interval_minutes', 3)))
ctk.CTkLabel(self.tab_system, text="동기화 대상 테이블", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
self.entry_sync_table = ctk.CTkEntry(self.tab_system)
self.entry_sync_table.pack(fill="x", padx=20, pady=(4, 8))
self.entry_sync_table.insert(0, str(sync_settings.get('table', 'posts')))
ctk.CTkLabel(
self.tab_system,
text="※ URL/KEY는 update 연결정보를 재사용합니다(기본).",
font=theme_manager.get_font(10),
text_color="gray"
).pack(anchor="w", padx=45, pady=(0, 0))
ctk.CTkButton(self.tab_system, text="설정 저장", command=self.save_all).pack(pady=20)
def _select_report_folder(self):
@ -377,6 +414,7 @@ class SettingsDialog(ctk.CTkToplevel):
self.controller.settings['login']['id'] = self.entry_id.get()
self.controller.settings['login']['pw'] = self.entry_pw.get()
self.controller.settings['crawling']['interval_minutes'] = int(self.combo_interval.get())
self.controller.settings['crawling']['recheck_hours'] = int(self.combo_recheck_hours.get())
self.controller.settings['crawling']['keywords'] = [k.strip() for k in self.entry_crawl_kw.get().split(',') if k.strip()]
self.controller.settings['crawling']['target_depts'] = [k.strip() for k in self.entry_dept.get().split(',') if k.strip()]
@ -420,6 +458,20 @@ class SettingsDialog(ctk.CTkToplevel):
self.controller.settings['update']['cleanup_backup_on_success'] = self.cleanup_backup_var.get()
self.controller.settings['update']['clean_install_before_copy'] = self.clean_install_var.get()
if 'sync' not in self.controller.settings:
self.controller.settings['sync'] = {}
self.controller.settings['sync']['enabled'] = self.sync_enabled_var.get()
self.controller.settings['sync']['pull_interval_minutes'] = int(self.combo_sync_interval.get())
self.controller.settings['sync']['table'] = self.entry_sync_table.get().strip() or 'posts'
self.controller.settings['sync'].setdefault('pull_batch_size', 500)
self.controller.settings['sync'].setdefault('timeout_sec', 10)
self.controller.settings['sync'].setdefault('use_update_connection', True)
self.controller.settings['sync'].setdefault('supabase_url', '')
self.controller.settings['sync'].setdefault('supabase_key', '')
self.controller.settings['sync'].setdefault('ssl_verify', True)
self.controller.settings['sync'].setdefault('ca_bundle_path', '')
self.controller.save_settings()
self.controller.update_config_runtime()

View File

@ -46,7 +46,7 @@ class TestSettingsDialogUpdatePolicy(unittest.TestCase):
controller = SimpleNamespace()
controller.settings = {
"login": {"id": "old", "pw": "old"},
"crawling": {"interval_minutes": 10, "keywords": [], "target_depts": []},
"crawling": {"interval_minutes": 10, "recheck_hours": 3, "keywords": [], "target_depts": []},
"noti": {"db_check_interval_minutes": 3, "unchecked_check_interval_minutes": 10, "unchecked_delay_enabled": True},
"theme": "Dark",
"report": {"output_path": "D:/reports"},
@ -69,6 +69,7 @@ class TestSettingsDialogUpdatePolicy(unittest.TestCase):
dialog.entry_id = _DummyField("116696")
dialog.entry_pw = _DummyField("pw")
dialog.combo_interval = _DummyField("10")
dialog.combo_recheck_hours = _DummyField("6")
dialog.entry_crawl_kw = _DummyField("1호선, 서면")
dialog.entry_dept = _DummyField("차량")
dialog.combo_noti_interval = _DummyField("3")
@ -84,17 +85,26 @@ class TestSettingsDialogUpdatePolicy(unittest.TestCase):
dialog.entry_preserve_globs = _DummyField("data/*.sqlite, data/*.json, data/*.db")
dialog.cleanup_backup_var = _DummyVar(True)
dialog.clean_install_var = _DummyVar(True)
dialog.sync_enabled_var = _DummyVar(True)
dialog.combo_sync_interval = _DummyField("5")
dialog.entry_sync_table = _DummyField("posts")
dialog.destroy = lambda: None
with patch("view.dialogs.settings_dialog.theme_manager.set_theme", lambda *_args, **_kwargs: None):
SettingsDialog.save_all(dialog)
crawling_settings = controller.settings["crawling"]
self.assertEqual(crawling_settings["recheck_hours"], 6)
update_settings = controller.settings["update"]
self.assertEqual(update_settings["allowed_update_levels"], ["patch", "major"])
self.assertEqual(update_settings["clean_install_levels"], ["minor", "major"])
self.assertEqual(update_settings["preserve_globs"], ["data/*.sqlite", "data/*.json", "data/*.db"])
self.assertTrue(update_settings["cleanup_backup_on_success"])
self.assertTrue(update_settings["clean_install_before_copy"])
sync_settings = controller.settings["sync"]
self.assertTrue(sync_settings["enabled"])
self.assertEqual(sync_settings["pull_interval_minutes"], 5)
self.assertEqual(sync_settings["table"], "posts")
self.assertEqual(controller.save_settings_called, 1)
self.assertEqual(controller.update_runtime_called, 1)