from pathlib import Path ROOT = Path(__file__).resolve().parents[1] def test_competitor_intel_cache_reuses_memory_and_shared_file(tmp_path, monkeypatch): from services import competitor_intel_repository as repo monkeypatch.setattr(repo, "_CACHE_FILE", Path(tmp_path) / "competitor_intel_cache.pkl") repo._MEM_CACHE.clear() calls = {"count": 0} def producer(): calls["count"] += 1 return {"valid_matches": 7, "match_rate": 0.1} first = repo._cached_payload("coverage:test", producer, ttl_seconds=60) second = repo._cached_payload("coverage:test", producer, ttl_seconds=60) repo._MEM_CACHE.clear() third = repo._cached_payload("coverage:test", producer, ttl_seconds=60) assert first == second == third == {"valid_matches": 7, "match_rate": 0.1} assert calls["count"] == 1 def test_clear_competitor_intel_cache_removes_shared_file(tmp_path, monkeypatch): from services import competitor_intel_repository as repo cache_file = Path(tmp_path) / "competitor_intel_cache.pkl" monkeypatch.setattr(repo, "_CACHE_FILE", cache_file) repo._MEM_CACHE["x"] = {"time": 1, "value": {"ok": True}} cache_file.write_bytes(b"stale") repo.clear_competitor_intel_cache() assert repo._MEM_CACHE == {} assert not cache_file.exists() def test_competitor_ppt_results_keep_pending_diagnostics_in_export(): source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8") assert "LEFT JOIN valid_competitor" in source assert "\"found\": found" in source assert "\"match_status\"" in source assert "\"candidate_count\"" in source assert "\"unit_comparison\"" in source assert "build_unit_price_comparison" in source assert "(vc.pchome_price IS NULL)" in source assert "(lm.momo_price - vc.pchome_price) AS price_diff" in source assert "((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100) AS price_diff_pct" in source def test_competitor_ppt_results_use_history_for_dated_reports(): source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8") route_source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8") assert "requested_historical_prices" in source assert "use_history_prices" in source assert "FROM competitor_price_history cph" in source assert "cph.crawled_at >= DATE(:start_date)" in source assert "cph.crawled_at < DATE(:end_date) + INTERVAL '1 day'" in source assert "pr.timestamp < DATE(:end_date) + INTERVAL '1 day'" in source assert "'competitor_price_history' AS competitor_source" in source assert "\"competitor_source\"" in source assert "\"pc_crawled_at\"" in source assert "指定期間 competitor_price_history" in route_source def test_competitor_coverage_counts_only_active_product_intersection(): source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8") coverage_source = source.split("def _fetch_competitor_coverage_uncached", 1)[1].split( "def _fetch_manual_review_summary", 1 )[0] assert "coverage:v4" in source assert "(SELECT COUNT(*) FROM valid_competitor) AS valid_matches" not in coverage_source assert "FROM latest_momo lm\n JOIN valid_competitor vc ON vc.sku = lm.sku" in coverage_source assert "WHERE lm.rn = 1) AS valid_matches" in coverage_source def test_competitor_ppt_and_ai_use_momo_minus_pchome_gap_direction(): ppt_source = (ROOT / "services" / "ppt_generator.py").read_text(encoding="utf-8") route_source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8") assert 'pc_wins = [r for r in found if r.get("price_diff", 0) > 10]' in ppt_source assert 'mo_wins = [r for r in found if r.get("price_diff", 0) < -10]' in ppt_source assert "平均價差 {avg_pct:+.1f}%(momo - PChome)" in ppt_source assert "price_diff = MOMO售價 − PChome售價" in route_source assert "正值=MOMO較貴、PChome低價壓力" in route_source def test_competitor_review_queue_is_canonical_unit_price_handoff(): source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8") daily_template = (ROOT / "templates" / "daily_sales.html").read_text(encoding="utf-8") growth_template = (ROOT / "templates" / "growth_analysis.html").read_text(encoding="utf-8") assert "def fetch_competitor_review_queue" in source assert "\"review_queue\": fetch_competitor_review_queue" in source assert "\"unit_comparable_count\"" in source assert "manual_review_summary" in source assert "manual_accept_count" in source assert "manual_reject_count" in source assert "manual_unit_price_count" in source assert "competitor_match_reviews" in source assert "\"status_label\"" in source assert "\"action_label\"" in source assert "build_unit_price_comparison" in source assert "需單位價覆核" in daily_template assert "人工採用" in daily_template assert "人工否決" in daily_template assert "人工單位價" in daily_template assert "competitor_intel.review_queue" in daily_template assert "coverage.unit_comparable_count" in growth_template assert "coverage.manual_accept_count" in growth_template assert "coverage.manual_reject_count" in growth_template assert "coverage.manual_unit_price_count" in growth_template def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint(): source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8") assert "EwoooC 商品營運視角" in source assert "待補資料不可當成成功配對" in source assert "高信心比對" in source assert "待補身份/價格" in source assert "需單位價比較" in source assert "單位價覆核樣本" in source assert "我方 = PChome" not in source assert "請以 PChome 視角" not in source def test_top_competitor_risks_reads_latest_momo_price_after_valid_competitor_filter(): source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8") risk_source = source.split("def _fetch_top_competitor_risks_uncached", 1)[1].split("def fetch_competitor_review_queue", 1)[0] assert "FROM valid_competitor vc" in risk_source assert "JOIN LATERAL" in risk_source assert "WHERE pr.product_id = p.id" in risk_source assert "ROW_NUMBER() OVER (PARTITION BY p.id" not in risk_source