""" SEO诊断服务单元测试 """ import pytest from app.services.seo_diagnosis import ( SEODiagnosisService, SEODiagnosisResult, SEODimensionScore, DiagnosisItem, SEORecommendation, DiagnosisStatus, DimensionName, TechnicalSEOData, OnPageSEOData, ContentQualityData, BacklinkData, UserExperienceData, diagnose_technical_seo, diagnose_on_page_seo, diagnose_content_quality, diagnose_backlinks, diagnose_user_experience, generate_recommendations, ) class TestDiagnosisStatus: """诊断状态枚举测试""" def test_status_values(self): """测试状态值""" assert DiagnosisStatus.PASS == "pass" assert DiagnosisStatus.WARNING == "warning" assert DiagnosisStatus.FAIL == "fail" class TestDimensionName: """维度名称枚举测试""" def test_dimension_names(self): """测试维度名称""" assert DimensionName.TECHNICAL_SEO == "技术SEO" assert DimensionName.ON_PAGE_SEO == "页面SEO" assert DimensionName.CONTENT_QUALITY == "内容质量" assert DimensionName.BACKLINK_ANALYSIS == "外链分析" assert DimensionName.USER_EXPERIENCE == "用户体验" class TestDiagnosisItem: """诊断项数据结构测试""" def test_create_item(self): """测试创建诊断项""" item = DiagnosisItem( name="测试项", status=DiagnosisStatus.PASS, description="测试描述", suggestion="测试建议", score=1.0, ) assert item.name == "测试项" assert item.status == DiagnosisStatus.PASS assert item.score == 1.0 def test_item_with_details(self): """测试带详情的诊断项""" item = DiagnosisItem( name="测试项", status=DiagnosisStatus.WARNING, description="测试描述", suggestion="测试建议", details={"key": "value"}, ) assert item.details == {"key": "value"} class TestSEODimensionScore: """维度评分数据结构测试""" def test_create_dimension_score(self): """测试创建维度评分""" dim = SEODimensionScore( name="测试维度", score=20.0, max_score=25.0, items=[], status=DiagnosisStatus.PASS, ) assert dim.score == 20.0 assert dim.max_score == 25.0 assert dim.percentage == 80.0 def test_percentage_calculation(self): """测试得分率计算""" dim = SEODimensionScore( name="测试维度", score=15.0, max_score=25.0, items=[], status=DiagnosisStatus.PASS, ) assert dim.percentage == 60.0 def test_status_calculation_all_pass(self): """测试全部通过时的状态""" items = [ DiagnosisItem(name="项1", status=DiagnosisStatus.PASS, description="", suggestion=""), DiagnosisItem(name="项2", status=DiagnosisStatus.PASS, description="", suggestion=""), ] dim = SEODimensionScore( name="测试维度", score=10.0, max_score=10.0, items=items, status=DiagnosisStatus.PASS, ) assert dim.status == DiagnosisStatus.PASS def test_status_calculation_with_warnings(self): """测试有警告时的状态""" items = [ DiagnosisItem(name="项1", status=DiagnosisStatus.PASS, description="", suggestion=""), DiagnosisItem(name="项2", status=DiagnosisStatus.WARNING, description="", suggestion=""), DiagnosisItem(name="项3", status=DiagnosisStatus.WARNING, description="", suggestion=""), ] dim = SEODimensionScore( name="测试维度", score=7.0, max_score=10.0, items=items, status=DiagnosisStatus.PASS, ) assert dim.status == DiagnosisStatus.WARNING def test_status_calculation_with_fails(self): """测试有失败时的状态""" items = [ DiagnosisItem(name="项1", status=DiagnosisStatus.FAIL, description="", suggestion=""), DiagnosisItem(name="项2", status=DiagnosisStatus.PASS, description="", suggestion=""), DiagnosisItem(name="项3", status=DiagnosisStatus.PASS, description="", suggestion=""), DiagnosisItem(name="项4", status=DiagnosisStatus.PASS, description="", suggestion=""), ] dim = SEODimensionScore( name="测试维度", score=7.0, max_score=10.0, items=items, status=DiagnosisStatus.PASS, ) # 1个FAIL在4个项中占25%,未超过30%,但有FAIL所以是WARNING assert dim.status == DiagnosisStatus.WARNING def test_status_calculation_many_fails(self): """测试大量失败时的状态""" items = [ DiagnosisItem(name="项1", status=DiagnosisStatus.FAIL, description="", suggestion=""), DiagnosisItem(name="项2", status=DiagnosisStatus.FAIL, description="", suggestion=""), DiagnosisItem(name="项3", status=DiagnosisStatus.FAIL, description="", suggestion=""), DiagnosisItem(name="项4", status=DiagnosisStatus.PASS, description="", suggestion=""), ] dim = SEODimensionScore( name="测试维度", score=5.0, max_score=10.0, items=items, status=DiagnosisStatus.PASS, ) assert dim.status == DiagnosisStatus.FAIL class TestSEODiagnosisResult: """诊断结果数据结构测试""" def test_create_result(self): """测试创建诊断结果""" result = SEODiagnosisResult( overall_score=75.0, dimensions=[], recommendations=[], ) assert result.overall_score == 75.0 assert result.health_level == "good" def test_health_level_excellent(self): """测试优秀等级""" result = SEODiagnosisResult( overall_score=85.0, dimensions=[], recommendations=[], ) assert result.health_level == "excellent" def test_health_level_good(self): """测试良好等级""" result = SEODiagnosisResult( overall_score=70.0, dimensions=[], recommendations=[], ) assert result.health_level == "good" def test_health_level_pass(self): """测试及格等级""" result = SEODiagnosisResult( overall_score=50.0, dimensions=[], recommendations=[], ) assert result.health_level == "pass" def test_health_level_danger(self): """测试危险等级""" result = SEODiagnosisResult( overall_score=30.0, dimensions=[], recommendations=[], ) assert result.health_level == "danger" def test_score_clamping(self): """测试分数限制""" result = SEODiagnosisResult( overall_score=150.0, dimensions=[], recommendations=[], ) assert result.overall_score == 100.0 result = SEODiagnosisResult( overall_score=-10.0, dimensions=[], recommendations=[], ) assert result.overall_score == 0.0 def test_to_dict(self): """测试字典转换""" result = SEODiagnosisResult( overall_score=75.0, dimensions=[], recommendations=[], ) d = result.to_dict() assert d["overall_score"] == 75.0 assert d["health_level"] == "good" assert d["health_level_label"] == "良好" assert "dimensions" in d assert "recommendations" in d class TestTechnicalSEODiagnosis: """技术SEO诊断测试""" def test_perfect_technical_seo(self): """测试完美技术SEO""" data = TechnicalSEOData( is_indexed=True, crawl_errors=0, lcp_seconds=2.0, fid_ms=50.0, cls_score=0.05, has_robots_txt=True, robots_txt_blocks_important=False, has_sitemap=True, sitemap_valid=True, url_structure_normalized=True, ) result = diagnose_technical_seo(data) assert result.score == result.max_score assert result.status == DiagnosisStatus.PASS def test_indexed_fail(self): """测试未索引情况""" data = TechnicalSEOData(is_indexed=False) result = diagnose_technical_seo(data) assert any(item.status == DiagnosisStatus.FAIL for item in result.items if item.name == "索引状态") def test_crawl_errors_warning(self): """测试少量爬取错误""" data = TechnicalSEOData(crawl_errors=3) result = diagnose_technical_seo(data) crawl_item = next(item for item in result.items if item.name == "爬取错误") assert crawl_item.status == DiagnosisStatus.WARNING def test_crawl_errors_fail(self): """测试大量爬取错误""" data = TechnicalSEOData(crawl_errors=10) result = diagnose_technical_seo(data) crawl_item = next(item for item in result.items if item.name == "爬取错误") assert crawl_item.status == DiagnosisStatus.FAIL def test_core_web_vitals_pass(self): """测试Core Web Vitals通过""" data = TechnicalSEOData( lcp_seconds=2.0, fid_ms=80.0, cls_score=0.05, ) result = diagnose_technical_seo(data) cwv_items = [item for item in result.items if item.name in ["LCP", "FID", "CLS"]] assert all(item.status == DiagnosisStatus.PASS for item in cwv_items) def test_core_web_vitals_warning(self): """测试Core Web Vitals警告""" data = TechnicalSEOData( lcp_seconds=3.0, fid_ms=200.0, cls_score=0.15, ) result = diagnose_technical_seo(data) cwv_items = [item for item in result.items if item.name in ["LCP", "FID", "CLS"]] assert any(item.status == DiagnosisStatus.WARNING for item in cwv_items) def test_core_web_vitals_fail(self): """测试Core Web Vitals失败""" data = TechnicalSEOData( lcp_seconds=5.0, fid_ms=400.0, cls_score=0.3, ) result = diagnose_technical_seo(data) cwv_items = [item for item in result.items if item.name in ["LCP", "FID", "CLS"]] assert all(item.status == DiagnosisStatus.FAIL for item in cwv_items) def test_robots_txt_blocks_important(self): """测试robots.txt阻止重要页面""" data = TechnicalSEOData( has_robots_txt=True, robots_txt_blocks_important=True, ) result = diagnose_technical_seo(data) robots_item = next(item for item in result.items if item.name == "robots.txt") assert robots_item.status == DiagnosisStatus.FAIL def test_missing_sitemap(self): """测试缺少sitemap""" data = TechnicalSEOData(has_sitemap=False) result = diagnose_technical_seo(data) sitemap_item = next(item for item in result.items if item.name == "sitemap") assert sitemap_item.status == DiagnosisStatus.FAIL class TestOnPageSEODiagnosis: """页面SEO诊断测试""" def test_perfect_on_page_seo(self): """测试完美页面SEO""" data = OnPageSEOData( has_title=True, title_length=50, title_keyword_stuffing=False, has_meta_description=True, meta_description_length=140, h1_count=1, h_structure_valid=True, keyword_density=2.0, internal_links=10, broken_internal_links=0, images_without_alt=0, total_images=5, ) result = diagnose_on_page_seo(data) assert result.score == result.max_score assert result.status == DiagnosisStatus.PASS def test_missing_title(self): """测试缺少Title""" data = OnPageSEOData(has_title=False) result = diagnose_on_page_seo(data) title_item = next(item for item in result.items if item.name == "Title标签") assert title_item.status == DiagnosisStatus.FAIL def test_title_too_long(self): """测试Title过长""" data = OnPageSEOData(title_length=80) result = diagnose_on_page_seo(data) title_item = next(item for item in result.items if item.name == "Title标签") assert title_item.status == DiagnosisStatus.WARNING def test_keyword_stuffing(self): """测试关键词堆砌""" data = OnPageSEOData(title_keyword_stuffing=True) result = diagnose_on_page_seo(data) title_item = next(item for item in result.items if item.name == "Title标签") assert title_item.status == DiagnosisStatus.WARNING def test_multiple_h1(self): """测试多个H1""" data = OnPageSEOData(h1_count=3) result = diagnose_on_page_seo(data) h_item = next(item for item in result.items if item.name == "H标签结构") assert h_item.status == DiagnosisStatus.WARNING def test_broken_links_warning(self): """测试少量死链""" data = OnPageSEOData(broken_internal_links=2) result = diagnose_on_page_seo(data) link_item = next(item for item in result.items if item.name == "内链结构") assert link_item.status == DiagnosisStatus.WARNING def test_broken_links_fail(self): """测试大量死链""" data = OnPageSEOData(broken_internal_links=10) result = diagnose_on_page_seo(data) link_item = next(item for item in result.items if item.name == "内链结构") assert link_item.status == DiagnosisStatus.FAIL def test_images_without_alt(self): """测试图片缺少Alt""" data = OnPageSEOData( images_without_alt=3, total_images=5, ) result = diagnose_on_page_seo(data) alt_item = next(item for item in result.items if item.name == "图片Alt文本") assert alt_item.status == DiagnosisStatus.FAIL class TestContentQualityDiagnosis: """内容质量诊断测试""" def test_perfect_content_quality(self): """测试完美内容质量""" data = ContentQualityData( readability_score=80.0, word_count=2000, topic_coverage=0.9, has_author_info=True, has_publication_date=True, last_updated_days=10, has_citations=True, citation_authority=0.9, duplicate_content_ratio=0.02, has_expert_review=True, ) result = diagnose_content_quality(data) assert result.score == result.max_score assert result.status == DiagnosisStatus.PASS def test_low_readability(self): """测试低可读性""" data = ContentQualityData(readability_score=40.0) result = diagnose_content_quality(data) readability_item = next(item for item in result.items if item.name == "可读性") assert readability_item.status == DiagnosisStatus.FAIL def test_shallow_content(self): """测试内容深度不足""" data = ContentQualityData( word_count=500, topic_coverage=0.4, ) result = diagnose_content_quality(data) depth_item = next(item for item in result.items if item.name == "信息深度") assert depth_item.status == DiagnosisStatus.FAIL def test_missing_author(self): """测试缺少作者信息""" data = ContentQualityData(has_author_info=False) result = diagnose_content_quality(data) author_item = next(item for item in result.items if item.name == "作者资质") assert author_item.status == DiagnosisStatus.WARNING def test_stale_content(self): """测试过时内容""" data = ContentQualityData(last_updated_days=200) result = diagnose_content_quality(data) freshness_item = next(item for item in result.items if item.name == "内容新鲜度") assert freshness_item.status == DiagnosisStatus.FAIL def test_high_duplicate_ratio(self): """测试高重复内容比例""" data = ContentQualityData(duplicate_content_ratio=0.5) result = diagnose_content_quality(data) duplicate_item = next(item for item in result.items if item.name == "重复内容") assert duplicate_item.status == DiagnosisStatus.FAIL class TestBacklinkDiagnosis: """外链分析诊断测试""" def test_perfect_backlinks(self): """测试完美外链""" data = BacklinkData( total_backlinks=200, referring_domains=50, high_authority_links=20, toxic_links=0, nofollow_ratio=0.3, anchor_text_diversity=0.9, exact_match_anchor_ratio=0.1, ) result = diagnose_backlinks(data) assert result.score == result.max_score assert result.status == DiagnosisStatus.PASS def test_few_referring_domains(self): """测试引用域名少""" data = BacklinkData(referring_domains=5) result = diagnose_backlinks(data) domain_item = next(item for item in result.items if item.name == "引用域名") assert domain_item.status == DiagnosisStatus.FAIL def test_toxic_links_warning(self): """测试少量毒性链接""" data = BacklinkData( total_backlinks=100, toxic_links=3, ) result = diagnose_backlinks(data) toxic_item = next(item for item in result.items if item.name == "毒性链接") assert toxic_item.status == DiagnosisStatus.WARNING def test_toxic_links_fail(self): """测试大量毒性链接""" data = BacklinkData( total_backlinks=50, toxic_links=10, ) result = diagnose_backlinks(data) toxic_item = next(item for item in result.items if item.name == "毒性链接") assert toxic_item.status == DiagnosisStatus.FAIL def test_low_anchor_diversity(self): """测试锚文本多样性低""" data = BacklinkData( anchor_text_diversity=0.3, exact_match_anchor_ratio=0.6, ) result = diagnose_backlinks(data) anchor_item = next(item for item in result.items if item.name == "锚文本分布") assert anchor_item.status == DiagnosisStatus.FAIL class TestUserExperienceDiagnosis: """用户体验诊断测试""" def test_perfect_ux(self): """测试完美用户体验""" data = UserExperienceData( is_mobile_friendly=True, mobile_viewport_set=True, page_load_time=1.5, has_https=True, has_breadcrumbs=True, conversion_path_clear=True, has_cta=True, form_usability=0.95, has_search=True, ) result = diagnose_user_experience(data) assert result.score == result.max_score assert result.status == DiagnosisStatus.PASS def test_not_mobile_friendly(self): """测试不移动友好""" data = UserExperienceData(is_mobile_friendly=False) result = diagnose_user_experience(data) mobile_item = next(item for item in result.items if item.name == "移动适配") assert mobile_item.status == DiagnosisStatus.FAIL def test_slow_page_load(self): """测试页面加载慢""" data = UserExperienceData(page_load_time=5.0) result = diagnose_user_experience(data) speed_item = next(item for item in result.items if item.name == "页面速度") assert speed_item.status == DiagnosisStatus.FAIL def test_missing_https(self): """测试缺少HTTPS""" data = UserExperienceData(has_https=False) result = diagnose_user_experience(data) https_item = next(item for item in result.items if item.name == "HTTPS") assert https_item.status == DiagnosisStatus.FAIL def test_missing_cta(self): """测试缺少CTA""" data = UserExperienceData(has_cta=False) result = diagnose_user_experience(data) cta_item = next(item for item in result.items if item.name == "CTA") assert cta_item.status == DiagnosisStatus.WARNING class TestRecommendations: """优化建议生成测试""" def test_generate_recommendations(self): """测试建议生成""" result = SEODiagnosisResult( overall_score=60.0, dimensions=[ SEODimensionScore( name="测试维度", score=10.0, max_score=20.0, items=[ DiagnosisItem( name="失败项", status=DiagnosisStatus.FAIL, description="描述", suggestion="修复建议", ), DiagnosisItem( name="警告项", status=DiagnosisStatus.WARNING, description="描述", suggestion="优化建议", ), DiagnosisItem( name="通过项", status=DiagnosisStatus.PASS, description="描述", suggestion="保持", ), ], status=DiagnosisStatus.WARNING, ), ], recommendations=[], ) recommendations = generate_recommendations(result) assert len(recommendations) == 2 assert recommendations[0].priority == "high" assert recommendations[1].priority == "medium" def test_recommendations_sorted_by_priority(self): """测试建议按优先级排序""" result = SEODiagnosisResult( overall_score=50.0, dimensions=[ SEODimensionScore( name="维度1", score=5.0, max_score=10.0, items=[ DiagnosisItem( name="警告项", status=DiagnosisStatus.WARNING, description="", suggestion="", ), ], status=DiagnosisStatus.WARNING, ), SEODimensionScore( name="维度2", score=5.0, max_score=10.0, items=[ DiagnosisItem( name="失败项", status=DiagnosisStatus.FAIL, description="", suggestion="", ), ], status=DiagnosisStatus.WARNING, ), ], recommendations=[], ) recommendations = generate_recommendations(result) assert recommendations[0].priority == "high" assert recommendations[1].priority == "medium" class TestSEODiagnosisService: """SEO诊断服务测试""" @pytest.fixture def service(self): """创建诊断服务实例""" return SEODiagnosisService() def test_full_diagnosis_with_defaults(self, service): """测试使用默认数据的完整诊断""" result = service.diagnose() assert isinstance(result, SEODiagnosisResult) assert 0 <= result.overall_score <= 100 assert len(result.dimensions) == 5 assert isinstance(result.recommendations, list) def test_diagnosis_returns_all_dimensions(self, service): """测试诊断返回所有维度""" result = service.diagnose() dimension_names = [dim.name for dim in result.dimensions] assert "技术SEO" in dimension_names assert "页面SEO" in dimension_names assert "内容质量" in dimension_names assert "外链分析" in dimension_names assert "用户体验" in dimension_names def test_diagnosis_with_custom_data(self, service): """测试使用自定义数据的诊断""" technical_data = TechnicalSEOData( is_indexed=False, crawl_errors=10, ) result = service.diagnose(technical_data=technical_data) assert result.overall_score < 100 def test_diagnose_technical_only(self, service): """测试仅技术SEO诊断""" result = service.diagnose_technical_only() assert isinstance(result, SEODimensionScore) assert result.name == DimensionName.TECHNICAL_SEO def test_diagnose_on_page_only(self, service): """测试仅页面SEO诊断""" result = service.diagnose_on_page_only() assert isinstance(result, SEODimensionScore) assert result.name == DimensionName.ON_PAGE_SEO def test_diagnose_content_only(self, service): """测试仅内容质量诊断""" result = service.diagnose_content_only() assert isinstance(result, SEODimensionScore) assert result.name == DimensionName.CONTENT_QUALITY def test_diagnose_backlinks_only(self, service): """测试仅外链分析""" result = service.diagnose_backlinks_only() assert isinstance(result, SEODimensionScore) assert result.name == DimensionName.BACKLINK_ANALYSIS def test_diagnose_ux_only(self, service): """测试仅用户体验诊断""" result = service.diagnose_ux_only() assert isinstance(result, SEODimensionScore) assert result.name == DimensionName.USER_EXPERIENCE def test_diagnosis_with_poor_data(self, service): """测试使用差数据的诊断""" technical_data = TechnicalSEOData( is_indexed=False, crawl_errors=20, lcp_seconds=6.0, fid_ms=500.0, cls_score=0.4, has_robots_txt=False, has_sitemap=False, ) on_page_data = OnPageSEOData( has_title=False, has_meta_description=False, h1_count=0, broken_internal_links=10, ) content_data = ContentQualityData( readability_score=30.0, word_count=200, last_updated_days=365, duplicate_content_ratio=0.6, ) backlink_data = BacklinkData( referring_domains=2, toxic_links=20, anchor_text_diversity=0.2, ) ux_data = UserExperienceData( is_mobile_friendly=False, page_load_time=8.0, has_https=False, ) result = service.diagnose( technical_data=technical_data, on_page_data=on_page_data, content_data=content_data, backlink_data=backlink_data, ux_data=ux_data, ) assert result.overall_score < 30 assert result.health_level == "danger" assert len(result.recommendations) > 0 assert any(rec.priority == "high" for rec in result.recommendations) def test_diagnosis_with_excellent_data(self, service): """测试使用优秀数据的诊断""" technical_data = TechnicalSEOData( is_indexed=True, crawl_errors=0, lcp_seconds=1.5, fid_ms=50.0, cls_score=0.03, ) on_page_data = OnPageSEOData( title_length=45, meta_description_length=140, keyword_density=2.0, ) content_data = ContentQualityData( readability_score=85.0, word_count=2500, topic_coverage=0.95, has_expert_review=True, last_updated_days=5, ) backlink_data = BacklinkData( referring_domains=50, high_authority_links=20, toxic_links=0, anchor_text_diversity=0.9, ) ux_data = UserExperienceData( page_load_time=1.2, form_usability=0.95, ) result = service.diagnose( technical_data=technical_data, on_page_data=on_page_data, content_data=content_data, backlink_data=backlink_data, ux_data=ux_data, ) assert result.overall_score >= 80 assert result.health_level == "excellent" def test_result_to_dict_format(self, service): """测试结果字典格式""" result = service.diagnose() d = result.to_dict() assert "overall_score" in d assert "health_level" in d assert "health_level_label" in d assert "dimensions" in d assert "recommendations" in d assert isinstance(d["dimensions"], list) assert isinstance(d["recommendations"], list) if d["dimensions"]: dim = d["dimensions"][0] assert "name" in dim assert "score" in dim assert "max_score" in dim assert "percentage" in dim assert "status" in dim assert "items" in dim