|
| 1 | +""" |
| 2 | +Verification of user-facing AI surface added in v5.15.0. |
| 3 | +
|
| 4 | +Follows the Verification Constitution: |
| 5 | +- Ground-truth assertions (specific values, not just `is not None`) |
| 6 | +- Silence checks (bad input produces useful errors) |
| 7 | +- Solvability (skills are discoverable and well-formed) |
| 8 | +""" |
| 9 | + |
| 10 | +import pytest |
| 11 | +import yaml |
| 12 | +from pathlib import Path |
| 13 | + |
| 14 | + |
| 15 | +# ============================================================================ |
| 16 | +# Skill YAML Validity — all skill files parse and have required fields |
| 17 | +# ============================================================================ |
| 18 | + |
| 19 | +SKILLS_ROOT = Path(__file__).parent.parent / "edgar" / "ai" / "skills" |
| 20 | + |
| 21 | +# Collect all skill.yaml files |
| 22 | +SKILL_YAML_FILES = sorted(SKILLS_ROOT.glob("**/skill.yaml")) |
| 23 | +SHARP_EDGES_FILES = sorted(SKILLS_ROOT.glob("**/sharp-edges.yaml")) |
| 24 | + |
| 25 | + |
| 26 | +@pytest.mark.fast |
| 27 | +@pytest.mark.parametrize("yaml_path", SKILL_YAML_FILES, ids=lambda p: str(p.relative_to(SKILLS_ROOT))) |
| 28 | +def test_skill_yaml_parses_and_has_required_fields(yaml_path): |
| 29 | + """Every skill.yaml must parse as valid YAML with id, name, and version.""" |
| 30 | + content = yaml_path.read_text(encoding="utf-8") |
| 31 | + data = yaml.safe_load(content) |
| 32 | + |
| 33 | + assert isinstance(data, dict), f"{yaml_path.name} should parse as a YAML mapping" |
| 34 | + assert "id" in data, f"{yaml_path} missing 'id' field" |
| 35 | + assert "name" in data, f"{yaml_path} missing 'name' field" |
| 36 | + assert "version" in data, f"{yaml_path} missing 'version' field" |
| 37 | + assert data["id"].startswith("edgartools-"), f"Skill id should start with 'edgartools-', got '{data['id']}'" |
| 38 | + |
| 39 | + |
| 40 | +@pytest.mark.fast |
| 41 | +@pytest.mark.parametrize("yaml_path", SHARP_EDGES_FILES, ids=lambda p: str(p.relative_to(SKILLS_ROOT))) |
| 42 | +def test_sharp_edges_yaml_parses(yaml_path): |
| 43 | + """Every sharp-edges.yaml must parse as valid YAML.""" |
| 44 | + content = yaml_path.read_text(encoding="utf-8") |
| 45 | + data = yaml.safe_load(content) |
| 46 | + assert isinstance(data, dict), f"{yaml_path.name} should parse as a YAML mapping" |
| 47 | + |
| 48 | + |
| 49 | +@pytest.mark.fast |
| 50 | +def test_skill_yaml_ids_are_unique(): |
| 51 | + """All skill.yaml files must have unique ids.""" |
| 52 | + ids = [] |
| 53 | + for yaml_path in SKILL_YAML_FILES: |
| 54 | + data = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) |
| 55 | + ids.append(data["id"]) |
| 56 | + |
| 57 | + assert len(ids) == len(set(ids)), f"Duplicate skill ids found: {[x for x in ids if ids.count(x) > 1]}" |
| 58 | + |
| 59 | + |
| 60 | +@pytest.mark.fast |
| 61 | +def test_all_six_skill_domains_present(): |
| 62 | + """EdgarTools ships 6 skill domains: core, financials, holdings, ownership, reports, xbrl.""" |
| 63 | + expected_domains = {"core", "financials", "holdings", "ownership", "reports", "xbrl"} |
| 64 | + actual_domains = {p.parent.name for p in SKILL_YAML_FILES} |
| 65 | + assert expected_domains == actual_domains, f"Missing domains: {expected_domains - actual_domains}" |
| 66 | + |
| 67 | + |
| 68 | +@pytest.mark.fast |
| 69 | +def test_core_skill_yaml_has_patterns(): |
| 70 | + """The core skill.yaml must define patterns (the primary content).""" |
| 71 | + core_yaml = SKILLS_ROOT / "core" / "skill.yaml" |
| 72 | + data = yaml.safe_load(core_yaml.read_text(encoding="utf-8")) |
| 73 | + |
| 74 | + assert "patterns" in data, "Core skill.yaml must have 'patterns'" |
| 75 | + assert len(data["patterns"]) >= 3, f"Core skill should have at least 3 patterns, got {len(data['patterns'])}" |
| 76 | + |
| 77 | + |
| 78 | +# ============================================================================ |
| 79 | +# Skills API — list, get, error paths |
| 80 | +# ============================================================================ |
| 81 | + |
| 82 | + |
| 83 | +@pytest.mark.fast |
| 84 | +def test_list_skills_returns_edgartools(): |
| 85 | + """list_skills() must return the EdgarTools skill.""" |
| 86 | + from edgar.ai import list_skills |
| 87 | + |
| 88 | + skills = list_skills() |
| 89 | + assert len(skills) == 1 |
| 90 | + assert skills[0].name == "EdgarTools" |
| 91 | + |
| 92 | + |
| 93 | +@pytest.mark.fast |
| 94 | +def test_get_skill_returns_correct_skill(): |
| 95 | + """get_skill('EdgarTools') returns a skill with name, description, and content_dir.""" |
| 96 | + from edgar.ai import get_skill |
| 97 | + |
| 98 | + skill = get_skill("EdgarTools") |
| 99 | + assert skill.name == "EdgarTools" |
| 100 | + assert "SEC" in skill.description |
| 101 | + assert skill.content_dir.exists() |
| 102 | + assert skill.content_dir.is_dir() |
| 103 | + |
| 104 | + |
| 105 | +@pytest.mark.fast |
| 106 | +def test_get_skill_bad_name_raises_with_available_list(): |
| 107 | + """get_skill() with invalid name must raise ValueError listing available skills.""" |
| 108 | + from edgar.ai import get_skill |
| 109 | + |
| 110 | + with pytest.raises(ValueError, match="EdgarTools") as exc_info: |
| 111 | + get_skill("NonExistentSkill") |
| 112 | + |
| 113 | + # The error message should help the user — it must mention what IS available |
| 114 | + assert "not found" in str(exc_info.value).lower() |
| 115 | + |
| 116 | + |
| 117 | +@pytest.mark.fast |
| 118 | +def test_skill_get_documents_returns_known_files(): |
| 119 | + """EdgarTools skill must expose documents including SKILL and readme.""" |
| 120 | + from edgar.ai.skills.core import edgartools_skill |
| 121 | + |
| 122 | + docs = edgartools_skill.get_documents() |
| 123 | + assert "SKILL" in docs, f"SKILL should be in documents, got: {docs}" |
| 124 | + assert "readme" in docs, f"readme should be in documents, got: {docs}" |
| 125 | + |
| 126 | + |
| 127 | +@pytest.mark.fast |
| 128 | +def test_skill_get_document_content_bad_name_raises(): |
| 129 | + """get_document_content() with bad name must raise FileNotFoundError with helpful message.""" |
| 130 | + from edgar.ai.skills.core import edgartools_skill |
| 131 | + |
| 132 | + with pytest.raises(FileNotFoundError, match="not found"): |
| 133 | + edgartools_skill.get_document_content("nonexistent-document") |
| 134 | + |
| 135 | + |
| 136 | +@pytest.mark.fast |
| 137 | +def test_skill_str_has_concrete_values(): |
| 138 | + """str(skill) must include the skill name, document count, and helper count.""" |
| 139 | + from edgar.ai.skills.core import edgartools_skill |
| 140 | + |
| 141 | + text = str(edgartools_skill) |
| 142 | + assert "EdgarTools" in text |
| 143 | + assert "Documents:" in text |
| 144 | + assert "Helper Functions:" in text |
| 145 | + # Ground truth: we know there are 5 helpers |
| 146 | + assert "5" in text |
| 147 | + |
| 148 | + |
| 149 | +@pytest.mark.fast |
| 150 | +def test_skill_repr_includes_class_name(): |
| 151 | + """repr(skill) must include the class name.""" |
| 152 | + from edgar.ai.skills.core import edgartools_skill |
| 153 | + |
| 154 | + assert "EdgarToolsSkill" in repr(edgartools_skill) |
| 155 | + |
| 156 | + |
| 157 | +# ============================================================================ |
| 158 | +# AI module API surface |
| 159 | +# ============================================================================ |
| 160 | + |
| 161 | + |
| 162 | +@pytest.mark.fast |
| 163 | +def test_ai_module_public_api(): |
| 164 | + """edgar.ai must export the documented public API.""" |
| 165 | + import edgar.ai as ai |
| 166 | + |
| 167 | + # Core classes |
| 168 | + assert hasattr(ai, "AIEnabled") |
| 169 | + assert hasattr(ai, "TokenOptimizer") |
| 170 | + assert hasattr(ai, "SemanticEnricher") |
| 171 | + |
| 172 | + # Skills |
| 173 | + assert hasattr(ai, "list_skills") |
| 174 | + assert hasattr(ai, "get_skill") |
| 175 | + assert hasattr(ai, "edgartools_skill") |
| 176 | + assert hasattr(ai, "BaseSkill") |
| 177 | + |
| 178 | + # Convenience functions |
| 179 | + assert hasattr(ai, "install_skill") |
| 180 | + assert hasattr(ai, "package_skill") |
| 181 | + assert hasattr(ai, "export_skill") |
| 182 | + |
| 183 | + # Status flags |
| 184 | + assert hasattr(ai, "AI_AVAILABLE") |
| 185 | + |
| 186 | + |
| 187 | +@pytest.mark.fast |
| 188 | +def test_get_ai_info_structure(): |
| 189 | + """get_ai_info() must return a dict with documented keys.""" |
| 190 | + from edgar.ai import get_ai_info |
| 191 | + |
| 192 | + info = get_ai_info() |
| 193 | + assert isinstance(info, dict) |
| 194 | + assert "ai_available" in info |
| 195 | + assert "mcp_available" in info |
| 196 | + assert "tiktoken_available" in info |
| 197 | + assert "missing_dependencies" in info |
| 198 | + assert isinstance(info["missing_dependencies"], list) |
0 commit comments