feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""Configure pytest to collect test files with hyphens in names."""
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_collect_file(parent, file_path):
|
||||
if file_path.suffix == ".py" and file_path.name.startswith("test-"):
|
||||
return pytest.Module.from_parent(parent, path=file_path)
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for analyze-input.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "analyze-input.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestAnalyzeInput:
|
||||
def test_basic_metrics(self):
|
||||
text = "Hello world\nThis is a test\nThree lines here"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["line_count"] == 3
|
||||
assert m["non_empty_line_count"] == 3
|
||||
assert m["word_count"] == 9
|
||||
assert m["character_count"] > 0
|
||||
|
||||
def test_detects_existing_structure(self):
|
||||
text = "[Verse 1]\nSome lyrics here\nMore lyrics\n\n[Chorus]\nChorus line"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["has_existing_structure"] is True
|
||||
assert "Verse 1" in m["existing_tags"]
|
||||
assert "Chorus" in m["existing_tags"]
|
||||
|
||||
def test_no_structure_detected(self):
|
||||
text = "Just raw text\nWith no brackets\nPlain poetry"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["has_existing_structure"] is False
|
||||
assert m["existing_tags"] == []
|
||||
|
||||
def test_repeated_phrases(self):
|
||||
text = "come back to me tonight\nwhen the stars are bright\ncome back to me tonight\nunder the pale moonlight"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
phrases = [p["phrase"] for p in m["repeated_phrases"]]
|
||||
assert any("come back to me" in p for p in phrases)
|
||||
|
||||
def test_rhyme_pairs(self):
|
||||
text = "Walking down the street\nFeeling the beat\nLooking for the light\nShining in the night"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
rhymes = m["potential_rhyme_pairs"]
|
||||
rhyme_words = [set(r["words"]) for r in rhymes]
|
||||
assert any({"street", "beat"} == w for w in rhyme_words) or any({"light", "night"} == w for w in rhyme_words)
|
||||
|
||||
def test_short_structure_estimate(self):
|
||||
text = "\n".join(f"Line {i}" for i in range(1, 10))
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["estimated_structure"] == "short"
|
||||
|
||||
def test_medium_structure_estimate(self):
|
||||
text = "\n".join(f"Line number {i} of the song" for i in range(1, 25))
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["estimated_structure"] == "medium"
|
||||
|
||||
def test_long_structure_estimate(self):
|
||||
text = "\n".join(f"Line number {i} of a very long song" for i in range(1, 35))
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["estimated_structure"] == "long"
|
||||
|
||||
def test_report_structure(self):
|
||||
report, code = run_script("--text", "Some text")
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "metrics" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "analyze" in result.stdout.lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for assemble-summary.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "assemble-summary.py")
|
||||
|
||||
|
||||
def run_script(*args, input_data=None):
|
||||
"""Run the script and return stdout and returncode."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True,
|
||||
input=input_data
|
||||
)
|
||||
return result.stdout, result.returncode
|
||||
|
||||
|
||||
def create_test_files(tmp_path):
|
||||
"""Create sample JSON input files for testing."""
|
||||
validation = {
|
||||
"script": "validate-lyrics",
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"total_lines": 20,
|
||||
"lyric_lines": 14,
|
||||
"section_count": 4,
|
||||
"sections": ["Verse 1", "Chorus", "Verse 2", "Chorus"]
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 0}
|
||||
}
|
||||
|
||||
syllables = {
|
||||
"script": "syllable-counter",
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"total_lyric_lines": 14,
|
||||
"total_syllables": 112,
|
||||
"average_syllables_per_line": 8.0,
|
||||
"min_syllables": 5,
|
||||
"max_syllables": 12
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 0}
|
||||
}
|
||||
|
||||
cliches = {
|
||||
"script": "cliche-detector",
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"total_cliches_found": 2,
|
||||
"categories": {"emotional": 1, "nature": 1}
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 2}
|
||||
}
|
||||
|
||||
val_file = tmp_path / "validation.json"
|
||||
syl_file = tmp_path / "syllables.json"
|
||||
cli_file = tmp_path / "cliches.json"
|
||||
|
||||
val_file.write_text(json.dumps(validation))
|
||||
syl_file.write_text(json.dumps(syllables))
|
||||
cli_file.write_text(json.dumps(cliches))
|
||||
|
||||
return str(val_file), str(syl_file), str(cli_file)
|
||||
|
||||
|
||||
class TestAssembleSummary:
|
||||
def test_basic_assembly(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script("--validation", val, "--syllables", syl, "--cliches", cli)
|
||||
assert code == 0
|
||||
assert "Transformation Summary" in output
|
||||
assert "Validation Status" in output
|
||||
assert "Sections:" in output
|
||||
|
||||
def test_with_transformations(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script(
|
||||
"--validation", val, "--syllables", syl, "--cliches", cli,
|
||||
"--transformations", "ST,CC,RA"
|
||||
)
|
||||
assert code == 0
|
||||
assert "Transformations Applied" in output
|
||||
assert "ST:" in output
|
||||
assert "CC:" in output
|
||||
assert "RA:" in output
|
||||
|
||||
def test_json_output(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
out_file = tmp_path / "output.json"
|
||||
output, code = run_script(
|
||||
"--validation", val, "--syllables", syl, "--cliches", cli,
|
||||
"-o", str(out_file)
|
||||
)
|
||||
assert code == 0
|
||||
report = json.loads(out_file.read_text())
|
||||
assert report["script"] == "assemble-summary"
|
||||
assert "metrics" in report
|
||||
assert "markdown" in report
|
||||
|
||||
def test_markdown_output_file(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
out_file = tmp_path / "output.md"
|
||||
output, code = run_script(
|
||||
"--validation", val, "--syllables", syl, "--cliches", cli,
|
||||
"-o", str(out_file)
|
||||
)
|
||||
assert code == 0
|
||||
content = out_file.read_text()
|
||||
assert "## Transformation Summary" in content
|
||||
|
||||
def test_cliche_categories_displayed(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script("--validation", val, "--syllables", syl, "--cliches", cli)
|
||||
assert code == 0
|
||||
assert "2 found" in output
|
||||
assert "emotional" in output
|
||||
assert "nature" in output
|
||||
|
||||
def test_syllable_range_displayed(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script("--validation", val, "--syllables", syl, "--cliches", cli)
|
||||
assert code == 0
|
||||
assert "5-12" in output
|
||||
assert "avg 8.0" in output
|
||||
|
||||
def test_estimated_duration(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script("--validation", val, "--syllables", syl, "--cliches", cli)
|
||||
assert code == 0
|
||||
# 4 sections * 15 sec = 60 sec = 1:00
|
||||
assert "1:00" in output
|
||||
|
||||
def test_missing_files_handled(self, tmp_path):
|
||||
missing = str(tmp_path / "nonexistent.json")
|
||||
output, code = run_script(
|
||||
"--validation", missing, "--syllables", missing, "--cliches", missing
|
||||
)
|
||||
assert code == 2
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "assemble" in result.stdout.lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for cliche-detector.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "cliche-detector.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestClicheDetector:
|
||||
def test_detects_fire_in_soul(self):
|
||||
report, code = run_script("--text", "There's a fire in my soul tonight")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
assert any("fire in my soul" in f["data"]["matched_text"] for f in report["findings"])
|
||||
|
||||
def test_detects_dance_in_rain(self):
|
||||
report, code = run_script("--text", "We'll dance in the rain together")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_detects_broken_heart(self):
|
||||
report, code = run_script("--text", "My broken heart won't heal")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_detects_stand_tall(self):
|
||||
report, code = run_script("--text", "I'm standing tall against the wind")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_no_cliches_in_clean_text(self):
|
||||
report, code = run_script("--text", "The kitchen table holds three plates\nSteam rising from the coffee cup")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] == 0
|
||||
assert code == 0
|
||||
|
||||
def test_skips_metatags(self):
|
||||
text = "[Verse 1]\nFire in my soul\n[Chorus]\nClean lyrics here"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
# Should find the cliche in the lyric line, not in metatags
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_provides_alternatives(self):
|
||||
report, code = run_script("--text", "Rise from the ashes of what we were")
|
||||
assert report is not None
|
||||
assert len(report["findings"]) > 0
|
||||
finding = report["findings"][0]
|
||||
assert "alternatives" in finding["data"]
|
||||
assert len(finding["data"]["alternatives"]) > 0
|
||||
|
||||
def test_multiple_cliches_in_one_text(self):
|
||||
text = (
|
||||
"Fire in my soul keeps burning bright\n"
|
||||
"Standing tall through broken dreams\n"
|
||||
"Dance in the rain with a heart of gold\n"
|
||||
)
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 3
|
||||
|
||||
def test_case_insensitive(self):
|
||||
report, code = run_script("--text", "FIRE IN MY SOUL")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_report_structure(self):
|
||||
report, code = run_script("--text", "Just a normal line")
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "metrics" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "cliche" in result.stdout.lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for lyrics-diff.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "lyrics-diff.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestLyricsDiff:
|
||||
def test_identical_lyrics(self):
|
||||
text = "[Verse 1]\nHello world\nGoodbye moon"
|
||||
report, code = run_script("--original-text", text, "--transformed-text", text)
|
||||
assert report is not None
|
||||
assert report["status"] == "pass"
|
||||
assert len(report["changes"]) == 0
|
||||
assert report["summary"]["lines_added"] == 0
|
||||
assert report["summary"]["lines_removed"] == 0
|
||||
assert report["summary"]["lines_modified"] == 0
|
||||
|
||||
def test_modified_line(self):
|
||||
original = "[Verse 1]\nWalking through the rain"
|
||||
transformed = "[Verse 1]\nRunning through the storm"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
assert report["status"] == "info"
|
||||
modified = [c for c in report["changes"] if c["type"] == "modified"]
|
||||
assert len(modified) >= 1
|
||||
assert report["summary"]["lines_modified"] >= 1
|
||||
|
||||
def test_added_lines(self):
|
||||
original = "[Verse 1]\nLine one"
|
||||
transformed = "[Verse 1]\nLine one\nLine two\nLine three"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
added = [c for c in report["changes"] if c["type"] == "added"]
|
||||
assert len(added) >= 1
|
||||
assert report["summary"]["lines_added"] >= 1
|
||||
|
||||
def test_removed_lines(self):
|
||||
original = "[Verse 1]\nLine one\nLine two\nLine three"
|
||||
transformed = "[Verse 1]\nLine one"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
removed = [c for c in report["changes"] if c["type"] == "removed"]
|
||||
assert len(removed) >= 1
|
||||
assert report["summary"]["lines_removed"] >= 1
|
||||
|
||||
def test_section_tracking(self):
|
||||
original = "[Verse 1]\nOld verse line\n\n[Chorus]\nOld chorus line"
|
||||
transformed = "[Verse 1]\nNew verse line\n\n[Chorus]\nNew chorus line"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
assert len(report["summary"]["sections_affected"]) >= 1
|
||||
|
||||
def test_unified_diff_output(self):
|
||||
original = "[Verse 1]\nHello"
|
||||
transformed = "[Verse 1]\nGoodbye"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
assert "unified_diff" in report
|
||||
assert len(report["unified_diff"]) > 0
|
||||
|
||||
def test_file_input(self, tmp_path):
|
||||
orig_file = tmp_path / "orig.txt"
|
||||
trans_file = tmp_path / "trans.txt"
|
||||
orig_file.write_text("[Verse 1]\nOriginal line")
|
||||
trans_file.write_text("[Verse 1]\nTransformed line")
|
||||
report, code = run_script("--original", str(orig_file), "--transformed", str(trans_file))
|
||||
assert report is not None
|
||||
assert len(report["changes"]) >= 1
|
||||
|
||||
def test_report_structure(self):
|
||||
report, code = run_script("--original-text", "a", "--transformed-text", "b")
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "changes" in report
|
||||
assert "summary" in report
|
||||
assert "unified_diff" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "diff" in result.stdout.lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for section-length-checker.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "section-length-checker.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestSectionLengthChecker:
|
||||
def test_sections_within_range(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Line one of the verse\n"
|
||||
"Line two of the verse\n"
|
||||
"Line three of the verse\n"
|
||||
"Line four of the verse\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Chorus line one\n"
|
||||
"Chorus line two\n"
|
||||
"Chorus line three\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert report["status"] == "pass"
|
||||
assert report["metrics"]["sections_pass"] == 2
|
||||
assert report["metrics"]["sections_fail"] == 0
|
||||
|
||||
def test_verse_too_short(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Only one line\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Chorus one\n"
|
||||
"Chorus two\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert report["status"] == "warning"
|
||||
short_sections = [s for s in report["sections"] if s["status"] == "short"]
|
||||
assert len(short_sections) >= 1
|
||||
assert short_sections[0]["base_name"] == "verse"
|
||||
|
||||
def test_verse_too_long(self):
|
||||
lyrics = "[Verse 1]\n" + "\n".join(f"Line {i}" for i in range(1, 12)) + "\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
long_sections = [s for s in report["sections"] if s["status"] == "long"]
|
||||
assert len(long_sections) >= 1
|
||||
|
||||
def test_intro_can_be_empty(self):
|
||||
lyrics = (
|
||||
"[Intro]\n"
|
||||
"\n"
|
||||
"[Verse 1]\n"
|
||||
"Line one\nLine two\nLine three\nLine four\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
intro = [s for s in report["sections"] if s["base_name"] == "intro"]
|
||||
assert len(intro) == 1
|
||||
assert intro[0]["status"] == "pass"
|
||||
|
||||
def test_numbered_sections_normalized(self):
|
||||
lyrics = (
|
||||
"[Verse 2]\n"
|
||||
"Line one\nLine two\nLine three\nLine four\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Chorus one\nChorus two\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
verse = [s for s in report["sections"] if s["tag"] == "Verse 2"]
|
||||
assert len(verse) == 1
|
||||
assert verse[0]["base_name"] == "verse"
|
||||
|
||||
def test_unknown_section_type(self):
|
||||
lyrics = "[Spoken Word]\nSome content\nMore content\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
unknown = [s for s in report["sections"] if s["status"] == "unknown"]
|
||||
assert len(unknown) >= 1
|
||||
|
||||
def test_report_structure(self):
|
||||
lyrics = "[Verse 1]\nLine one\nLine two\nLine three\nLine four\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "sections" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "section" in result.stdout.lower()
|
||||
|
||||
def test_descriptor_metatags_not_counted_as_content(self):
|
||||
"""Descriptor metatags like [Energy: slow] should not inflate line counts."""
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"[Energy: slow]\n"
|
||||
"[Vocal Style: clean]\n"
|
||||
"[Mood: dark]\n"
|
||||
"Line one of the verse\n"
|
||||
"Line two of the verse\n"
|
||||
"Line three of the verse\n"
|
||||
"Line four of the verse\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
verse = [s for s in report["sections"] if s["base_name"] == "verse"]
|
||||
assert len(verse) == 1
|
||||
# Should count only 4 lyric lines, not 7
|
||||
assert verse[0]["line_count"] == 4
|
||||
assert verse[0]["status"] == "pass"
|
||||
|
||||
def test_prog_genre_relaxes_verse_limit(self):
|
||||
"""With --genre prog, verses can have up to 16 lines without warning."""
|
||||
lines = "\n".join(f"Line {i}" for i in range(1, 13))
|
||||
lyrics = f"[Verse 1]\n{lines}\n"
|
||||
# Without genre flag, 12 lines should be too long (max 8)
|
||||
report_normal, _ = run_script("--text", lyrics)
|
||||
assert report_normal is not None
|
||||
verse_normal = [s for s in report_normal["sections"] if s["base_name"] == "verse"]
|
||||
assert verse_normal[0]["status"] == "long"
|
||||
|
||||
# With prog genre, 12 lines should pass (max becomes 16)
|
||||
report_prog, _ = run_script("--text", lyrics, "--genre", "prog")
|
||||
assert report_prog is not None
|
||||
verse_prog = [s for s in report_prog["sections"] if s["base_name"] == "verse"]
|
||||
assert verse_prog[0]["status"] == "pass"
|
||||
|
||||
def test_interlude_is_known_section(self):
|
||||
"""Interlude should now be a known section type with defined range."""
|
||||
lyrics = "[Interlude]\nSome content\nMore content\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
interlude = [s for s in report["sections"] if s["base_name"] == "interlude"]
|
||||
assert len(interlude) == 1
|
||||
assert interlude[0]["status"] == "pass"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for syllable-counter.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "syllable-counter.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
# Also test the count_syllables function directly
|
||||
import importlib.util
|
||||
_spec = importlib.util.spec_from_file_location("syllable_counter", Path(__file__).parent.parent / "syllable-counter.py")
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
count_syllables = _mod.count_syllables
|
||||
estimate_duration = _mod.estimate_duration
|
||||
format_duration_range = _mod.format_duration_range
|
||||
|
||||
|
||||
class TestSyllableCounting:
|
||||
"""Test individual word syllable counting."""
|
||||
|
||||
def test_one_syllable_words(self):
|
||||
for word in ["cat", "dog", "the", "run", "light", "dream"]:
|
||||
assert count_syllables(word) == 1, f"Expected 1 syllable for '{word}', got {count_syllables(word)}"
|
||||
|
||||
def test_two_syllable_words(self):
|
||||
for word in ["hello", "window", "walking", "morning", "shadow"]:
|
||||
assert count_syllables(word) == 2, f"Expected 2 syllables for '{word}', got {count_syllables(word)}"
|
||||
|
||||
def test_three_syllable_words(self):
|
||||
for word in ["beautiful", "another", "everyone", "different"]:
|
||||
result = count_syllables(word)
|
||||
assert result == 3, f"Expected 3 syllables for '{word}', got {result}"
|
||||
|
||||
def test_contractions(self):
|
||||
assert count_syllables("I'm") == 1
|
||||
assert count_syllables("don't") == 1
|
||||
assert count_syllables("couldn't") == 2
|
||||
|
||||
def test_empty_string(self):
|
||||
assert count_syllables("") == 0
|
||||
|
||||
|
||||
class TestLyricsAnalysis:
|
||||
"""Test full lyrics analysis via the script."""
|
||||
|
||||
def test_basic_analysis(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Walking through the morning light\n"
|
||||
"Counting shadows on the wall\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert report["script"] == "syllable-counter"
|
||||
assert report["metrics"]["total_lyric_lines"] == 2
|
||||
assert report["metrics"]["total_syllables"] > 0
|
||||
|
||||
def test_section_grouping(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Short line here\n"
|
||||
"Another short one\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"The chorus comes in strong and bold\n"
|
||||
"With longer lines that carry more weight\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert len(report["section_analysis"]) == 2
|
||||
section_names = [s["section"] for s in report["section_analysis"]]
|
||||
assert "[Verse 1]" in section_names
|
||||
assert "[Chorus]" in section_names
|
||||
|
||||
def test_line_data_includes_syllables(self):
|
||||
lyrics = "[Verse 1]\nHello world\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert len(report["line_data"]) == 1
|
||||
assert "syllables" in report["line_data"][0]
|
||||
assert report["line_data"][0]["syllables"] > 0
|
||||
|
||||
def test_skips_metatags(self):
|
||||
lyrics = "[Mood: haunting]\n[Verse 1]\nWalking through fog\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
# Only the lyric line should be counted, not metatags
|
||||
assert report["metrics"]["total_lyric_lines"] == 1
|
||||
|
||||
def test_high_variance_warning(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Hi\n"
|
||||
"This is a much longer line with many more syllables than the first\n"
|
||||
"Short\n"
|
||||
"Another really long line that goes on and on and on\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
# Should flag high syllable variance
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("variance" in i.lower() or "syllable" in i.lower() for i in issues)
|
||||
|
||||
def test_report_structure(self):
|
||||
lyrics = "[Verse 1]\nA simple test line\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "metrics" in report
|
||||
assert "line_data" in report
|
||||
assert "section_analysis" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "syllable" in result.stdout.lower()
|
||||
|
||||
|
||||
class TestDurationEstimation:
|
||||
"""Test duration estimation function."""
|
||||
|
||||
def test_zero_lines(self):
|
||||
min_s, max_s = estimate_duration(0, 0)
|
||||
assert min_s == 0
|
||||
assert max_s == 0
|
||||
|
||||
def test_one_line(self):
|
||||
# 7.0 avg syllables = mid range (3.0-4.5 secs/line)
|
||||
min_s, max_s = estimate_duration(1, 7.0)
|
||||
assert min_s == round(1 * 3.5) # low-density range
|
||||
assert max_s == round(1 * 5.5)
|
||||
|
||||
def test_typical_song(self):
|
||||
# 20 lines at 7.0 avg syllables (mid range)
|
||||
min_s, max_s = estimate_duration(20, 7.0)
|
||||
assert min_s == round(20 * 3.5) # 70
|
||||
assert max_s == round(20 * 5.5) # 110
|
||||
|
||||
def test_high_density_faster(self):
|
||||
# High syllable density = faster delivery = less time per line
|
||||
min_s, max_s = estimate_duration(20, 12.0)
|
||||
assert min_s == round(20 * 2.5) # 50
|
||||
assert max_s == round(20 * 4.0) # 80
|
||||
|
||||
def test_instrumental_sections_add_time(self):
|
||||
# Sections with instrumental tags add time
|
||||
sections = [
|
||||
{"name": "[Intro]", "lines": []},
|
||||
{"name": "[Verse]", "lines": [{"syllables": 7}] * 4},
|
||||
{"name": "[Guitar Solo]", "lines": []},
|
||||
{"name": "[Outro]", "lines": []},
|
||||
]
|
||||
min_s, max_s = estimate_duration(4, 7.0, sections)
|
||||
# 4 lines at mid range + intro (5-15) + guitar solo (10-25) + outro (8-20)
|
||||
assert min_s > round(4 * 3.5) # More than just lyrics
|
||||
assert max_s > round(4 * 5.5)
|
||||
|
||||
def test_formatted_range(self):
|
||||
formatted = format_duration_range(50, 90)
|
||||
assert formatted == "0:50-1:30"
|
||||
|
||||
def test_formatted_range_zero(self):
|
||||
formatted = format_duration_range(0, 0)
|
||||
assert formatted == "0:00-0:00"
|
||||
|
||||
def test_formatted_range_large(self):
|
||||
formatted = format_duration_range(120, 240)
|
||||
assert formatted == "2:00-4:00"
|
||||
|
||||
def test_duration_in_report(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Walking through the morning light\n"
|
||||
"Counting shadows on the wall\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
duration = report["metrics"]["estimated_duration"]
|
||||
assert "min_seconds" in duration
|
||||
assert "max_seconds" in duration
|
||||
assert "formatted" in duration
|
||||
assert duration["min_seconds"] > 0
|
||||
assert duration["max_seconds"] > duration["min_seconds"]
|
||||
# Check formatted string pattern M:SS-M:SS
|
||||
assert "-" in duration["formatted"]
|
||||
|
||||
def test_estimate_duration_flag(self):
|
||||
lyrics = "[Verse 1]\nHello world\n"
|
||||
report, code = run_script("--text", lyrics, "--estimate-duration")
|
||||
assert report is not None
|
||||
assert "estimated_duration" in report["metrics"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for validate-lyrics.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "validate-lyrics.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestValidateLyrics:
|
||||
def test_valid_structured_lyrics(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Walking through the morning light\n"
|
||||
"Counting shadows on the wall\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone, come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
"\n"
|
||||
"[Verse 2]\n"
|
||||
"Fingerprints on frosted glass\n"
|
||||
"Letters folded into cranes\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone, come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert report["script"] == "validate-lyrics"
|
||||
assert report["metrics"]["section_count"] == 4
|
||||
assert "Verse 1" in report["metrics"]["sections"]
|
||||
assert "Chorus" in report["metrics"]["sections"]
|
||||
|
||||
def test_no_section_tags(self):
|
||||
lyrics = "Just some raw text\nWith no structure at all\nThree lines of poetry"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("No section metatags" in i for i in issues)
|
||||
|
||||
def test_style_cue_contamination(self):
|
||||
lyrics = "[Verse 1]\nThe punchy drums echo through my mind\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("style cue" in i.lower() for i in issues)
|
||||
|
||||
def test_asterisk_detection(self):
|
||||
lyrics = "[Verse 1]\n*This line has asterisks*\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("Asterisk" in i for i in issues)
|
||||
|
||||
def test_empty_lyrics(self):
|
||||
report, code = run_script("--text", "")
|
||||
assert report is not None
|
||||
assert report["status"] == "fail"
|
||||
assert any(f["severity"] == "critical" for f in report["findings"])
|
||||
|
||||
def test_unrecognized_metatag(self):
|
||||
lyrics = "[Verse 1]\nSome line\n\n[Banana]\nAnother line\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("Unrecognized metatag" in i for i in issues)
|
||||
|
||||
def test_valid_descriptor_metatags(self):
|
||||
lyrics = (
|
||||
"[Mood: haunting]\n\n"
|
||||
"[Verse 1]\n"
|
||||
"Walking through the fog\n"
|
||||
"Counting all the windows\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
# Descriptor metatags should not be flagged as unrecognized
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert not any("Mood" in i and "Unrecognized" in i for i in issues)
|
||||
|
||||
def test_empty_section(self):
|
||||
lyrics = "[Verse 1]\n\n[Chorus]\nSome chorus line\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("Empty section" in i for i in issues)
|
||||
|
||||
def test_report_structure(self):
|
||||
lyrics = "[Verse 1]\nA simple test line here\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "metrics" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "validate" in result.stdout.lower()
|
||||
|
||||
def test_character_count_error(self):
|
||||
"""Lyrics exceeding 5000 chars (hard limit) should produce high severity finding."""
|
||||
# Build lyrics over 5000 characters (hard limit for v4.5+/v5/v5.5)
|
||||
line = "This is a long line of lyrics for testing character count limits yeah\n"
|
||||
lyrics = "[Verse 1]\n" + line * 80 # well over 5000 chars
|
||||
assert len(lyrics) > 5000
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f for f in report["findings"] if "character count" in f["issue"].lower()]
|
||||
assert len(issues) >= 1
|
||||
assert any(f["severity"] == "high" for f in issues)
|
||||
|
||||
def test_character_count_warning(self):
|
||||
"""Lyrics between 3000 and 5000 chars should produce medium severity finding (quality degrades)."""
|
||||
# Build lyrics between 3000 and 5000 characters (quality budget exceeded)
|
||||
line = "This is a medium line of lyrics for testing\n"
|
||||
base = "[Verse 1]\n"
|
||||
# Each line is 45 chars. Need total between 3000 and 5000.
|
||||
count = 72 # 10 + 72*45 = 3250
|
||||
lyrics = base + line * count
|
||||
total = len(lyrics)
|
||||
assert 3000 < total < 5000, f"Got {total} chars"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f for f in report["findings"] if "character count" in f["issue"].lower()]
|
||||
assert len(issues) >= 1
|
||||
assert any(f["severity"] == "medium" for f in issues)
|
||||
|
||||
def test_character_count_in_metrics(self):
|
||||
"""Report metrics should include character_count."""
|
||||
lyrics = "[Verse 1]\nHello world\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert "character_count" in report["metrics"]
|
||||
assert report["metrics"]["character_count"] == len(lyrics)
|
||||
|
||||
def test_punctuation_density_detection(self):
|
||||
"""Lines with heavy punctuation should trigger a rhythm finding."""
|
||||
lyrics = "[Verse 1]\nwell, - ; : ... yes\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f for f in report["findings"] if "punctuation" in f["issue"].lower()]
|
||||
assert len(issues) >= 1
|
||||
assert issues[0]["severity"] == "low"
|
||||
assert issues[0]["category"] == "rhythm"
|
||||
|
||||
def test_clean_lyrics_normal_punctuation_passes(self):
|
||||
"""Clean lyrics with normal punctuation should pass without punctuation findings."""
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Walking through the morning light\n"
|
||||
"Counting shadows on the wall\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone, come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
"\n"
|
||||
"[Verse 2]\n"
|
||||
"Fingerprints on frosted glass\n"
|
||||
"Letters folded into cranes\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone, come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
punct_issues = [f for f in report["findings"] if "punctuation" in f["issue"].lower()]
|
||||
assert len(punct_issues) == 0
|
||||
|
||||
def test_new_section_tags_recognized(self):
|
||||
"""New section tags like Guitar Solo, Instrumental, etc. should not be flagged."""
|
||||
new_tags = [
|
||||
"Guitar Solo", "Piano Solo", "Sax Solo", "Saxophone Solo",
|
||||
"Drum Solo", "Bass Solo", "Solo", "Instrumental", "Interlude",
|
||||
"Build", "Build-Up", "Buildup", "Drop", "Hook", "Refrain",
|
||||
"Post-Chorus", "End", "Fade Out", "Fade In", "Break",
|
||||
]
|
||||
for tag in new_tags:
|
||||
lyrics = f"[Verse 1]\nSome line one\nSome line two\n\n[{tag}]\nContent here\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None, f"No report for [{tag}]"
|
||||
unrecognized = [f for f in report["findings"]
|
||||
if "Unrecognized" in f.get("issue", "") and tag in f.get("issue", "")]
|
||||
assert len(unrecognized) == 0, f"[{tag}] was flagged as unrecognized"
|
||||
|
||||
def test_vocal_cues_recognized(self):
|
||||
"""Vocal delivery cues like [Harmonized] should not be flagged as unrecognized."""
|
||||
cues = ["Harmonized", "Hummed", "Humming", "Whistled", "Whistling",
|
||||
"Crooning", "Scat", "Call and Response"]
|
||||
for cue in cues:
|
||||
lyrics = f"[Verse 1]\nSome line here\n[{cue}]\nMore content\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None, f"No report for [{cue}]"
|
||||
unrecognized = [f for f in report["findings"]
|
||||
if "Unrecognized" in f.get("issue", "") and cue in f.get("issue", "")]
|
||||
assert len(unrecognized) == 0, f"[{cue}] was flagged as unrecognized"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for validate-options.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "validate-options.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestValidateOptions:
|
||||
def test_all_valid_codes(self):
|
||||
report, code = run_script("ST,CC,RA,CD")
|
||||
assert report is not None
|
||||
assert report["script"] == "validate-options"
|
||||
assert report["status"] == "pass"
|
||||
assert set(report["validated_codes"]) == {"ST", "CC", "RA", "CD"}
|
||||
assert report["removed_codes"] == []
|
||||
|
||||
def test_invalid_code(self):
|
||||
report, code = run_script("ST,ZZ,RA")
|
||||
assert report is not None
|
||||
assert report["status"] == "error"
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("ZZ" in i for i in issues)
|
||||
|
||||
def test_fr_wf_mutual_exclusion(self):
|
||||
report, code = run_script("FR,WF")
|
||||
assert report is not None
|
||||
assert report["status"] == "error"
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("mutually exclusive" in i.lower() for i in issues)
|
||||
|
||||
def test_fr_auto_removes_ce(self):
|
||||
report, code = run_script("FR,CE,RA")
|
||||
assert report is not None
|
||||
assert "CE" in report["removed_codes"]
|
||||
assert "CE" not in report["validated_codes"]
|
||||
assert "FR" in report["validated_codes"]
|
||||
assert "RA" in report["validated_codes"]
|
||||
|
||||
def test_ce_cc_info_note(self):
|
||||
report, code = run_script("CE,CC")
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("CC" in i and "redundant" in i.lower() for i in issues)
|
||||
# CC should still be in validated codes (info only, not removed)
|
||||
assert "CC" in report["validated_codes"]
|
||||
|
||||
def test_empty_codes(self):
|
||||
report, code = run_script("--codes", "")
|
||||
assert report is not None
|
||||
assert report["status"] == "error"
|
||||
assert any(f["severity"] == "critical" for f in report["findings"])
|
||||
|
||||
def test_codes_flag(self):
|
||||
report, code = run_script("--codes", "ST,RA")
|
||||
assert report is not None
|
||||
assert report["status"] == "pass"
|
||||
assert set(report["validated_codes"]) == {"ST", "RA"}
|
||||
|
||||
def test_duplicate_codes(self):
|
||||
report, code = run_script("ST,ST,RA")
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("Duplicate" in i for i in issues)
|
||||
assert report["validated_codes"].count("ST") == 1
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "validate" in result.stdout.lower()
|
||||
|
||||
def test_report_structure(self):
|
||||
report, code = run_script("ST")
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "validated_codes" in report
|
||||
assert "removed_codes" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user