| 1 | """Validate the editorial style guide contains all required safety sections. |
| 2 | |
| 3 | This test ensures the Signal Check editorial style guide maintains required |
| 4 | safety boundaries, disclosure requirements, and structural constraints that |
| 5 | prevent unsafe content generation. |
| 6 | """ |
| 7 | |
| 8 | import re |
| 9 | from pathlib import Path |
| 10 | |
| 11 | import pytest |
| 12 | |
| 13 | GUIDE_PATH = Path(__file__).resolve().parent.parent / "docs" / "editorial-style-guide.md" |
| 14 | SEGMENT_TABLE_PATTERN = re.compile( |
| 15 | r"^\|\s*\d+\s*\|\s*\*\*(?P<segment>[^*]+)\*\*\s*\|", re.MULTILINE |
| 16 | ) |
| 17 | |
| 18 | |
| 19 | @pytest.fixture |
| 20 | def guide_content() -> str: |
| 21 | assert GUIDE_PATH.exists(), f"Editorial style guide not found at {GUIDE_PATH}" |
| 22 | return GUIDE_PATH.read_text(encoding="utf-8") |
| 23 | |
| 24 | |
| 25 | class TestEditorialStyleGuideStructure: |
| 26 | """Verify the guide contains all required structural elements.""" |
| 27 | |
| 28 | def test_guide_exists(self): |
| 29 | assert GUIDE_PATH.exists() |
| 30 | |
| 31 | def test_has_host_roles(self, guide_content: str): |
| 32 | assert "Host A" in guide_content |
| 33 | assert "Host B" in guide_content |
| 34 | assert "Curator" in guide_content |
| 35 | assert "Skeptic" in guide_content |
| 36 | |
| 37 | def test_has_all_segments_in_order(self, guide_content: str): |
| 38 | required_segments = [ |
| 39 | "Cold Open", |
| 40 | "The Signal", |
| 41 | "The Noise Check", |
| 42 | "The Gap", |
| 43 | "Receipts Round", |
| 44 | "Week Ahead", |
| 45 | "Outro", |
| 46 | ] |
| 47 | for segment in required_segments: |
| 48 | assert segment in guide_content, f"Missing segment: {segment}" |
| 49 | # Verify locked order (segments must not be reordered) |
| 50 | positions = [guide_content.index(s) for s in required_segments] |
| 51 | assert positions == sorted(positions), "Segments are not in the required locked order" |
| 52 | |
| 53 | def test_has_word_count_target(self, guide_content: str): |
| 54 | assert "1,200" in guide_content |
| 55 | assert "1,700" in guide_content |
| 56 | |
| 57 | |
| 58 | class TestEditorialStyleGuideSafety: |
| 59 | """Verify safety boundaries are documented.""" |
| 60 | |
| 61 | def test_ai_disclosure_requirement(self, guide_content: str): |
| 62 | lower = guide_content.lower() |
| 63 | assert "first 60 seconds" in lower, "Must require AI disclosure in first 60 seconds" |
| 64 | assert "ai-generated voice" in lower or "ai_generated" in lower, ( |
| 65 | "Must mention AI-generated voice disclosure" |
| 66 | ) |
| 67 | # Disclosure should also be required in the outro |
| 68 | assert "outro" in lower |
| 69 | |
| 70 | def test_prohibited_content_section(self, guide_content: str): |
| 71 | required_prohibitions = [ |
| 72 | "Real-person mimicry", |
| 73 | "Copied expression", |
| 74 | "Unsupported facts", |
| 75 | "Defamatory motive", |
| 76 | "Fake sponsorship", |
| 77 | "Individual-targeting humor", |
| 78 | "Financial advice", |
| 79 | "Security vulnerability disclosure", |
| 80 | ] |
| 81 | for prohibition in required_prohibitions: |
| 82 | assert prohibition in guide_content, f"Missing prohibition: {prohibition}" |
| 83 | |
| 84 | def test_corrections_path(self, guide_content: str): |
| 85 | assert "corrections" in guide_content.lower() |
| 86 | # Must link to the specific SquadScope issues page, not just any github.com URL |
| 87 | assert "github.com/jmservera/SquadScope/issues" in guide_content, ( |
| 88 | "Corrections path must link to the SquadScope issues page" |
| 89 | ) |
| 90 | |
| 91 | def test_claim_ledger_requirement(self, guide_content: str): |
| 92 | assert "claim ledger" in guide_content.lower() |
| 93 | assert "must map" in guide_content.lower() or "MUST map" in guide_content |
| 94 | |
| 95 | def test_distinctiveness_requirements(self, guide_content: str): |
| 96 | lower = guide_content.lower() |
| 97 | assert "hard fork" in lower, "Must reference Hard Fork as distinctiveness example" |
| 98 | assert "must not replicate" in lower, ( |
| 99 | "Must state the requirement not to replicate other podcasts" |
| 100 | ) |
| 101 | |
| 102 | def test_sponsorship_guardrails(self, guide_content: str): |
| 103 | assert "FTC" in guide_content or "sponsorship" in guide_content.lower() |
| 104 | assert "brought to you by" in guide_content.lower() |
| 105 | |
| 106 | |
| 107 | class TestPodcastConfigAlignedWithGuide: |
| 108 | """Verify config/podcast.json aligns with the editorial style guide.""" |
| 109 | |
| 110 | @pytest.fixture |
| 111 | def podcast_config(self) -> dict: |
| 112 | import json |
| 113 | |
| 114 | config_path = Path(__file__).resolve().parent.parent / "config" / "podcast.json" |
| 115 | assert config_path.exists(), "config/podcast.json not found" |
| 116 | return json.loads(config_path.read_text(encoding="utf-8")) |
| 117 | |
| 118 | def test_config_references_style_guide(self, podcast_config: dict): |
| 119 | assert "editorial_style_guide" in podcast_config |
| 120 | guide_path = ( |
| 121 | Path(__file__).resolve().parent.parent / podcast_config["editorial_style_guide"] |
| 122 | ) |
| 123 | assert guide_path.exists(), ( |
| 124 | f"Style guide path {podcast_config['editorial_style_guide']} does not exist" |
| 125 | ) |
| 126 | |
| 127 | def test_segment_order_matches_guide(self, podcast_config: dict, guide_content: str): |
| 128 | config_segments = podcast_config["script_directions"]["episode_style"]["segment_order"] |
| 129 | guide_segments = [match.strip() for match in SEGMENT_TABLE_PATTERN.findall(guide_content)] |
| 130 | |
| 131 | assert guide_segments, "Could not extract locked segment order from style guide" |
| 132 | assert len(guide_segments) == len(set(guide_segments)), ( |
| 133 | "Style guide contains duplicate segments" |
| 134 | ) |
| 135 | assert config_segments == guide_segments, ( |
| 136 | "Podcast config segment_order must exactly match the style guide's locked segment order " |
| 137 | f"(guide={guide_segments}, config={config_segments})" |
| 138 | ) |
| 139 | |
| 140 | def test_ai_disclosure_in_config(self, podcast_config: dict): |
| 141 | opening = podcast_config["script_directions"]["opening_cues"] |
| 142 | assert "ai_disclosure" in opening |
| 143 | assert "first 60 seconds" in opening["ai_disclosure"].lower() |
| 144 | |
| 145 | def test_word_count_target_in_config(self, podcast_config: dict): |
| 146 | fmt = podcast_config["script_directions"]["episode_style"]["format"] |
| 147 | assert "1200" in fmt or "1,200" in fmt |
| 148 | assert "1700" in fmt or "1,700" in fmt |