238 lines
9.8 KiB
Python
238 lines
9.8 KiB
Python
"""
|
||
tests/test_openclaw_daily_template.py
|
||
─────────────────────────────────────────────────────────────────
|
||
Operation Ollama-First v5.0 / Phase 3 / A8 — 日報模板路由測試
|
||
|
||
驗證面:
|
||
T1. flag=false → 走 _legacy_full_gemini_daily_report(緊急退路)
|
||
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 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_full_gemini_daily_report 被呼叫"""
|
||
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
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# 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
|