""" tests/test_openclaw_daily_template.py ───────────────────────────────────────────────────────────────── Operation Ollama-First v5.0 / Phase 3 / A8 — 日報模板路由測試 驗證面: T1. flag=false → 走相容 legacy 函式,但函式內仍是 Ollama-first T2. flag=true(預設)→ 走 _generate_daily_report_hermes_template T3. _compute_daily_kpi 各 KPI 函數可獨立 mock 測(DB 失敗回安全預設) T4. _render_daily_template_v2 缺欄位優雅降級(_SafeUndefined 不 raise) T5. _SafeUndefined 對 'X.Y.Z' 巢狀存取不爆 紀律: - 不打真實 DB / Gemini API - 不寫 ai_insights - 不發 Telegram """ import os import logging from datetime import date, datetime from pathlib import Path from unittest.mock import patch, MagicMock import pytest # ═══════════════════════════════════════════════════════════════════════════ # Fixtures # ═══════════════════════════════════════════════════════════════════════════ @pytest.fixture(autouse=True) def _reset_flag(monkeypatch): """每個 test 前清環境變數,避免互相污染""" monkeypatch.delenv('OPENCLAW_DAILY_HERMES_TEMPLATE', raising=False) yield # ═══════════════════════════════════════════════════════════════════════════ # T1+T2 — Routing # ═══════════════════════════════════════════════════════════════════════════ class TestRouting: def test_flag_false_routes_to_legacy(self, monkeypatch): """flag=false → 相容 legacy 函式被呼叫;Gemini 仍只可在函式內當備援""" monkeypatch.setenv('OPENCLAW_DAILY_HERMES_TEMPLATE', 'false') import importlib import services.openclaw_strategist_service as svc importlib.reload(svc) legacy_called = {'v': False} hermes_called = {'v': False} def mock_legacy(): legacy_called['v'] = True return {'status': 'ok', 'mode': 'legacy'} def mock_hermes(): hermes_called['v'] = True return {'status': 'ok', 'mode': 'hermes_template'} monkeypatch.setattr(svc, '_legacy_full_gemini_daily_report', mock_legacy) monkeypatch.setattr(svc, '_generate_daily_report_hermes_template', mock_hermes) svc.generate_daily_report() assert legacy_called['v'] is True, "flag=false 必須走 legacy 路徑" assert hermes_called['v'] is False, "flag=false 不可走 hermes 模板" def test_flag_true_routes_to_hermes_template(self, monkeypatch): """flag=true → _generate_daily_report_hermes_template 被呼叫""" monkeypatch.setenv('OPENCLAW_DAILY_HERMES_TEMPLATE', 'true') import importlib import services.openclaw_strategist_service as svc importlib.reload(svc) legacy_called = {'v': False} hermes_called = {'v': False} monkeypatch.setattr(svc, '_legacy_full_gemini_daily_report', lambda: legacy_called.update(v=True) or {'status': 'ok'}) monkeypatch.setattr(svc, '_generate_daily_report_hermes_template', lambda: hermes_called.update(v=True) or {'status': 'ok'}) svc.generate_daily_report() assert hermes_called['v'] is True, "flag=true 必須走 hermes 模板路徑" assert legacy_called['v'] is False, "flag=true 不可走 legacy" def test_flag_default_is_true(self, monkeypatch): """無 env 設定時 → 預設 true(Hermes/Ollama-first)""" # 不 set env import importlib import services.openclaw_strategist_service as svc importlib.reload(svc) assert svc._daily_hermes_template_enabled() is True # Operation Ollama-First v5.0:預設翻 ON # ═══════════════════════════════════════════════════════════════════════════ # T3 — KPI 計算(DB 失敗安全降級) # ═══════════════════════════════════════════════════════════════════════════ class TestKPIComputation: def test_compute_daily_kpi_invalid_date_raises(self): import services.openclaw_strategist_service as svc with pytest.raises(TypeError): svc._compute_daily_kpi("not-a-date") def test_revenue_kpi_returns_safe_default_on_db_error(self, monkeypatch): """DB 異常時 _query_revenue_kpi 回零(不拋 exception)""" import services.openclaw_strategist_service as svc class _BrokenSession: def execute(self, *a, **kw): raise RuntimeError('DB connection lost') def close(self): pass monkeypatch.setattr(svc, 'get_session', lambda: _BrokenSession()) result = svc._query_revenue_kpi(date(2026, 5, 3)) assert result['today'] == 0.0 assert result['dod_pct'] == 0.0 assert result['wow_pct'] == 0.0 def test_fetch_top_threats_logs_malformed_metadata(self, monkeypatch, caplog): import services.openclaw_strategist_service as svc class _Rows: def fetchall(self): return [ ("SKU-1", "price gap", 0.91, "{bad-json", datetime(2026, 5, 13)), ] class _Session: def execute(self, *a, **kw): return _Rows() def close(self): pass monkeypatch.setattr(svc, "get_session", lambda: _Session()) caplog.set_level(logging.DEBUG, logger="services.openclaw_strategist_service") threats = svc._fetch_top_threats(1) assert threats[0]["sku"] == "SKU-1" assert threats[0]["gap_pct"] == 0 assert "metadata_json decode failed" in caplog.text def test_competitor_summary_tracks_rescore_review_bucket(self): source = (Path(__file__).resolve().parents[1] / "services/openclaw_strategist_service.py").read_text(encoding="utf-8") assert "rescore_accepted_count" in source assert "fetch_competitor_coverage" in source assert "summarize_review_decision_envelopes" in source assert "review_decision_text" in source assert "重算待人工覆核" in source # ═══════════════════════════════════════════════════════════════════════════ # T4+T5 — Template 渲染與缺欄位優雅降級 # ═══════════════════════════════════════════════════════════════════════════ class TestTemplateRendering: def test_render_with_full_context_succeeds(self): import services.openclaw_strategist_service as svc context = { 'today': '2026年05月02日', 'weekday': '週五', 'revenue': { 'today': 1234567.0, 'yesterday': 1100000.0, 'avg_7d': 1050000.0, 'dod_pct': 12.2, 'wow_pct': 17.6, }, 'orders': { 'today_rows': 234, 'today_sku': 187, 'avg_value_today': 5276.0, }, 'top_skus': [ {'name': 'SKU-A', 'qty': 50, 'revenue': 100000}, {'name': 'SKU-B', 'qty': 32, 'revenue': 80000}, ], 'price_gaps': [ {'sku_name': '商品X', 'momo_price': 1200, 'comp_price': 980, 'gap_pct': 22.4, 'competitor': 'PChome'}, ], 'inventory_alerts': [], 'priority_actions': ['對 SKU-A 啟動 EA 流程', '觀察 PChome 補貼'], 'gemini_insight': '今日營收強勁成長,建議加碼家電促銷檔期。', } rendered = svc._render_daily_template_v2(context) assert '2026年05月02日' in rendered assert '週五' in rendered assert 'NT$1,234,567' in rendered assert 'SKU-A' in rendered assert '商品X' in rendered assert 'PChome' in rendered assert '今日營收強勁成長' in rendered def test_render_with_missing_fields_does_not_raise(self): """_SafeUndefined 對缺欄位回 — 不拋 UndefinedError""" import services.openclaw_strategist_service as svc context = { 'today': '2026年05月02日', 'weekday': '週五', 'revenue': {'today': 0.0, 'dod_pct': 0.0, 'wow_pct': 0.0}, 'orders': {}, # 空 dict 'top_skus': [], 'price_gaps': [], 'inventory_alerts': [], 'priority_actions': [], 'gemini_insight': '', } # 不 raise 即過 rendered = svc._render_daily_template_v2(context) assert isinstance(rendered, str) assert len(rendered) > 0 # 缺欄位該降級為 — 或預設值 assert '今日無熱銷資料' in rendered or '✅' in rendered def test_safe_undefined_nested_access(self): """_SafeUndefined 對 'X.Y.Z' 巢狀存取不爆""" import services.openclaw_strategist_service as svc # 完全無 'revenue' 也不該 raise context = { 'today': '2026年05月02日', 'weekday': '週五', # 故意省略 revenue / orders / top_skus 等 } rendered = svc._render_daily_template_v2(context) assert isinstance(rendered, str) assert '2026年05月02日' in rendered