Files
ewoooc/tests/test_openclaw_daily_template.py
OoO 0bdb993914
All checks were successful
CD Pipeline / deploy (push) Successful in 55s
補 OpenClaw 報表資料解析診斷
2026-05-13 12:53:57 +08:00

238 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 設定時 → 預設 trueHermes/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