From fe5e071066017eba8ef96b1fa1570f65a80614f7 Mon Sep 17 00:00:00 2001 From: cmos dev Date: Fri, 10 Apr 2026 22:01:56 -0400 Subject: [PATCH] linter: expand to 21 rules covering all 8 source types (v0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the defense-in-depth gap flagged after iter 6: chapter, magazine, newspaper, social_media, podcast, and video exemplars had ZERO applicable linter rules, so their 100% linter pass rate was trivially true. Every exemplar now has 3-6 structural rules firing. Bump LINTER_VERSION to v0.3.0. Per harness discipline, this invalidates prior run logs' scores for cross-version comparison (they stay in the log as history). The re-baseline run still scores scalar 1.000, canary 1.000 on all 14 exemplars — formatter was already producing output that matches the new structural conventions, so tightening the linter didn't surface any regressions. New rules (12): - chapter_book_title_italicized - chapter_in_book_marker - magazine_name_italicized - newspaper_name_italicized - periodical_comma_before_date (magazine + newspaper; not journal) - social_media_post_quoted - social_media_platform_comma_date - podcast_series_italicized - podcast_episode_quoted - podcast_format_label - video_title_quoted (in quotes, NOT italicized) - video_format_label Extended: - article_title_quoted now covers journal, magazine, newspaper, and accepts ?/! as terminal punctuation Each rule has a positive + negative unit test with the CMOS/Purdue source cited in the docstring. Tests: 48 → 78 (30 new). Per-exemplar applicable-rule counts after this change: book 4 journal 5-6 web 3 chapter 4 magazine 5 social 4 podcast 5 video 4 newspaper 5 --- src/cmos/linter.py | 308 +++++++++++++++++++++++++++++++++++++- tests/test_linter.py | 348 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 646 insertions(+), 10 deletions(-) diff --git a/src/cmos/linter.py b/src/cmos/linter.py index 20940b9..852eb7c 100644 --- a/src/cmos/linter.py +++ b/src/cmos/linter.py @@ -24,7 +24,7 @@ from typing import Callable from harness.score import Exemplar -LINTER_VERSION = "v0.2.0" +LINTER_VERSION = "v0.3.0" @dataclass @@ -153,20 +153,30 @@ def rule_journal_title_italicized(candidate: str, exemplar: Exemplar) -> RuleRes ) +_PERIODICAL_TYPES = ("journal", "magazine", "newspaper") + + def rule_article_title_quoted(candidate: str, exemplar: Exemplar) -> RuleResult: """Article titles are wrapped in double quotes, with the trailing period inside the quotes. Source: Purdue OWL CMOS 18 NB poster page 3 "Bibliography: Basics" — "titles of articles, chapters, poems, etc. are placed in quotation marks." CMOS also places the terminating period inside the closing quote - (American style). + (American style). Applies to journal, magazine, and newspaper articles. """ - if exemplar.type != "journal": + if exemplar.type not in _PERIODICAL_TYPES: return RuleResult(name="article_title_quoted", applicable=False, passed=True) article_title = exemplar.canonical.get("article_title") if not article_title: return RuleResult(name="article_title_quoted", applicable=False, passed=True) - passed = f'"{article_title}."' in candidate + # Period OR question mark OR exclamation point closing — CMOS preserves + # terminal punctuation that's part of the title (e.g., "Are Flax Seeds All + # That?"). + variants = ( + f'"{article_title}."', + f'"{article_title}"' if article_title[-1] in "?!" else None, + ) + passed = any(v is not None and v in candidate for v in variants) return RuleResult( name="article_title_quoted", applicable=True, @@ -243,6 +253,284 @@ def rule_web_page_title_quoted(candidate: str, exemplar: Exemplar) -> RuleResult ) +# --- Chapter rules ------------------------------------------------------------ + + +def rule_chapter_book_title_italicized(candidate: str, exemplar: Exemplar) -> RuleResult: + """For a chapter in an edited volume, the enclosing book's title is italicized. + + Source: Purdue OWL CMOS 18 NB page 3, "Article, Chapter, Essay, etc., in + a Book or Edited Collection" — the book title is italicized like any + other book title. + """ + if exemplar.type != "chapter": + return RuleResult(name="chapter_book_title_italicized", applicable=False, passed=True) + book_title = exemplar.canonical.get("book_title") + if not book_title: + return RuleResult(name="chapter_book_title_italicized", applicable=False, passed=True) + passed = f"*{book_title}*" in candidate + return RuleResult( + name="chapter_book_title_italicized", + applicable=True, + passed=passed, + message="" if passed else f"enclosing book title '{book_title}' must be wrapped in *...*", + ) + + +def rule_chapter_in_book_marker(candidate: str, exemplar: Exemplar) -> RuleResult: + """Chapter entries include the keyword "In" before the italicized book title. + + Source: Purdue OWL CMOS 18 NB page 3 example: "Chilson, Peter. 'The + Border.' In *The Best American Travel Writing 2008*, ..." The "In" + keyword distinguishes a chapter from a standalone book. + """ + if exemplar.type != "chapter": + return RuleResult(name="chapter_in_book_marker", applicable=False, passed=True) + # "In *" — capital I, capital N, space, asterisk marking the start of the + # italicized book title. Strict; catches "in *" (lowercase) as a failure. + passed = " In *" in candidate or candidate.startswith("In *") + return RuleResult( + name="chapter_in_book_marker", + applicable=True, + passed=passed, + message="" if passed else "chapter entry must contain 'In *BookTitle*'", + ) + + +# --- Magazine / newspaper rules ----------------------------------------------- + + +def rule_magazine_name_italicized(candidate: str, exemplar: Exemplar) -> RuleResult: + """Magazine titles are italicized. + + Source: CMOS 18 quick guide example (e.g., *New Yorker*, December 18, + 2023). + """ + if exemplar.type != "magazine": + return RuleResult(name="magazine_name_italicized", applicable=False, passed=True) + magazine = exemplar.canonical.get("magazine") + if not magazine: + return RuleResult(name="magazine_name_italicized", applicable=False, passed=True) + passed = f"*{magazine}*" in candidate + return RuleResult( + name="magazine_name_italicized", + applicable=True, + passed=passed, + message="" if passed else f"magazine title '{magazine}' must be wrapped in *...*", + ) + + +def rule_newspaper_name_italicized(candidate: str, exemplar: Exemplar) -> RuleResult: + """Newspaper titles are italicized. + + Source: CMOS 18 quick guide example (e.g., *New York Times*, December + 13, 2023). + """ + if exemplar.type != "newspaper": + return RuleResult(name="newspaper_name_italicized", applicable=False, passed=True) + newspaper = exemplar.canonical.get("newspaper") + if not newspaper: + return RuleResult(name="newspaper_name_italicized", applicable=False, passed=True) + passed = f"*{newspaper}*" in candidate + return RuleResult( + name="newspaper_name_italicized", + applicable=True, + passed=passed, + message="" if passed else f"newspaper title '{newspaper}' must be wrapped in *...*", + ) + + +def rule_periodical_comma_before_date(candidate: str, exemplar: Exemplar) -> RuleResult: + """Magazine and newspaper entries use a comma between the italic name and the date. + + Journals use a different structure (volume, issue, year in parens), so + this rule only applies to magazines and newspapers. + + Source: CMOS 18 quick guide examples — "*New Yorker*, December 18, + 2023."; "*New York Times*, December 13, 2023." + """ + if exemplar.type not in ("magazine", "newspaper"): + return RuleResult(name="periodical_comma_before_date", applicable=False, passed=True) + name = exemplar.canonical.get("magazine") or exemplar.canonical.get("newspaper") + if not name: + return RuleResult(name="periodical_comma_before_date", applicable=False, passed=True) + passed = f"*{name}*, " in candidate + return RuleResult( + name="periodical_comma_before_date", + applicable=True, + passed=passed, + message=( + "" + if passed + else f"'*{name}*' must be followed by a comma-space, not a period or nothing" + ), + ) + + +# --- Social media rules ------------------------------------------------------- + + +def rule_social_media_post_quoted(candidate: str, exemplar: Exemplar) -> RuleResult: + """Social media post content is wrapped in straight double quotes. + + Source: CMOS 18 quick guide example: 'Chicago Manual of Style. "Is the + world ready for singular they? ..." Facebook, April 17, 2015. URL.' + Original post casing must be preserved (this rule checks for the + canonical form literally — if the formatter title-cases the post, the + match fails). + """ + if exemplar.type != "social_media": + return RuleResult(name="social_media_post_quoted", applicable=False, passed=True) + post_text = exemplar.canonical.get("post_text") + if not post_text: + return RuleResult(name="social_media_post_quoted", applicable=False, passed=True) + passed = f'"{post_text}"' in candidate + return RuleResult( + name="social_media_post_quoted", + applicable=True, + passed=passed, + message="" if passed else "post content must appear verbatim inside double quotes", + ) + + +def rule_social_media_platform_comma_date(candidate: str, exemplar: Exemplar) -> RuleResult: + """Social media entries use a comma between the platform name and the date. + + Source: CMOS 18 quick guide: '... Facebook, April 17, 2015. URL.' + """ + if exemplar.type != "social_media": + return RuleResult( + name="social_media_platform_comma_date", applicable=False, passed=True + ) + platform = exemplar.canonical.get("platform") + if not platform: + return RuleResult( + name="social_media_platform_comma_date", applicable=False, passed=True + ) + passed = f"{platform}, " in candidate + return RuleResult( + name="social_media_platform_comma_date", + applicable=True, + passed=passed, + message=( + "" + if passed + else f"'{platform}' must be followed by a comma-space before the date" + ), + ) + + +# --- Podcast rules ------------------------------------------------------------ + + +def rule_podcast_series_italicized(candidate: str, exemplar: Exemplar) -> RuleResult: + """Podcast series titles are italicized like book titles. + + Source: CMOS 18 quick guide example: 'Ober, Lauren, host. *The Loudest + Girl in the World*. Season 1, episode 2, ...' + """ + if exemplar.type != "podcast": + return RuleResult(name="podcast_series_italicized", applicable=False, passed=True) + podcast = exemplar.canonical.get("podcast") + if not podcast: + return RuleResult(name="podcast_series_italicized", applicable=False, passed=True) + passed = f"*{podcast}*" in candidate + return RuleResult( + name="podcast_series_italicized", + applicable=True, + passed=passed, + message="" if passed else f"podcast series '{podcast}' must be wrapped in *...*", + ) + + +def rule_podcast_episode_quoted(candidate: str, exemplar: Exemplar) -> RuleResult: + """Podcast episode titles are in straight double quotes. + + Source: CMOS 18 quick guide example (Ober). Terminal punctuation (! ? + .) that's part of the title must also be inside the closing quote. + """ + if exemplar.type != "podcast": + return RuleResult(name="podcast_episode_quoted", applicable=False, passed=True) + episode = exemplar.canonical.get("episode_title") + if not episode: + return RuleResult(name="podcast_episode_quoted", applicable=False, passed=True) + # Accept the episode with its source-provided trailing punctuation OR + # with an added period (CMOS American-style period inside quote). + variants = [f'"{episode}"', f'"{episode}."'] + passed = any(v in candidate for v in variants) + return RuleResult( + name="podcast_episode_quoted", + applicable=True, + passed=passed, + message="" if passed else f'episode title must appear in "{episode}"', + ) + + +def rule_podcast_format_label(candidate: str, exemplar: Exemplar) -> RuleResult: + """Podcast entries include a 'Podcast,' format label before the duration. + + Source: CMOS 18 quick guide example: '... Pushkin Industries, + September 13, 2022. Podcast, 41 min., 37 sec. URL.' + """ + if exemplar.type != "podcast": + return RuleResult(name="podcast_format_label", applicable=False, passed=True) + passed = "Podcast, " in candidate + return RuleResult( + name="podcast_format_label", + applicable=True, + passed=passed, + message="" if passed else "podcast entry must contain the 'Podcast, ' format label", + ) + + +# --- Video rules -------------------------------------------------------------- + + +def rule_video_title_quoted(candidate: str, exemplar: Exemplar) -> RuleResult: + """Video titles are in straight double quotes (not italicized). + + Unlike books/journals/podcasts, video/TED-talk titles are treated as + shorter works and put in quotes. + + Source: CMOS 18 quick guide example: 'Cowan, Vaitea. "How Green + Hydrogen Could End the Fossil Fuel Era." TED Talk, ...' + """ + if exemplar.type != "video": + return RuleResult(name="video_title_quoted", applicable=False, passed=True) + title = exemplar.canonical.get("title") + if not title: + return RuleResult(name="video_title_quoted", applicable=False, passed=True) + # Must be in quotes, not in italic markers. + passed = f'"{title}."' in candidate and f"*{title}*" not in candidate + return RuleResult( + name="video_title_quoted", + applicable=True, + passed=passed, + message=( + "" + if passed + else f'video title must appear as "{title}." (in quotes, not italicized)' + ), + ) + + +def rule_video_format_label(candidate: str, exemplar: Exemplar) -> RuleResult: + """Video entries include a 'Video,' format label before the duration. + + Source: CMOS 18 quick guide example: '... April 2022. Video, 9 min., + 15 sec. URL.' + """ + if exemplar.type != "video": + return RuleResult(name="video_format_label", applicable=False, passed=True) + passed = "Video, " in candidate + return RuleResult( + name="video_format_label", + applicable=True, + passed=passed, + message="" if passed else "video entry must contain the 'Video, ' format label", + ) + + RULES: list[RuleFn] = [ rule_ends_with_period, rule_no_place_of_publication_for_books, @@ -253,6 +541,18 @@ RULES: list[RuleFn] = [ rule_page_range_uses_en_dash, rule_doi_is_https_url, rule_web_page_title_quoted, + rule_chapter_book_title_italicized, + rule_chapter_in_book_marker, + rule_magazine_name_italicized, + rule_newspaper_name_italicized, + rule_periodical_comma_before_date, + rule_social_media_post_quoted, + rule_social_media_platform_comma_date, + rule_podcast_series_italicized, + rule_podcast_episode_quoted, + rule_podcast_format_label, + rule_video_title_quoted, + rule_video_format_label, ] diff --git a/tests/test_linter.py b/tests/test_linter.py index d68f80c..82e69a6 100644 --- a/tests/test_linter.py +++ b/tests/test_linter.py @@ -15,12 +15,24 @@ from cmos.linter import ( lint, rule_article_title_quoted, rule_book_title_italicized, + rule_chapter_book_title_italicized, + rule_chapter_in_book_marker, rule_doi_is_https_url, rule_ends_with_period, rule_journal_title_italicized, + rule_magazine_name_italicized, + rule_newspaper_name_italicized, rule_no_ibid, rule_no_place_of_publication_for_books, rule_page_range_uses_en_dash, + rule_periodical_comma_before_date, + rule_podcast_episode_quoted, + rule_podcast_format_label, + rule_podcast_series_italicized, + rule_social_media_platform_comma_date, + rule_social_media_post_quoted, + rule_video_format_label, + rule_video_title_quoted, rule_web_page_title_quoted, ) from harness.score import Exemplar @@ -81,6 +93,114 @@ WEB = Exemplar( expected_bibliography='Google. "Privacy Policy." Privacy & Terms. Effective November 15, 2023. https://policies.google.com/privacy.', ) +CHAPTER = Exemplar( + name="chapter", + source="test", + type="chapter", + tags=["chapter"], + canary=False, + messy_input="", + canonical={ + "author": "Doyle, Kathleen", + "chapter_title": "The Queen Mary Psalter", + "book_title": "The Book by Design: The Remarkable Story of the World's Greatest Invention", + "editors": "edited by P. J. M. Marks and Stephen Parkin", + "publisher": "University of Chicago Press", + "year": 2023, + }, + expected_bibliography='Doyle, Kathleen. "The Queen Mary Psalter." In *The Book by Design: The Remarkable Story of the World\'s Greatest Invention*, edited by P. J. M. Marks and Stephen Parkin. University of Chicago Press, 2023.', +) + +MAGAZINE = Exemplar( + name="magazine", + source="test", + type="magazine", + tags=["magazine"], + canary=False, + messy_input="", + canonical={ + "author": "Mead, Rebecca", + "article_title": "Terms of Aggrievement", + "magazine": "New Yorker", + "date": "December 18, 2023", + }, + expected_bibliography='Mead, Rebecca. "Terms of Aggrievement." *New Yorker*, December 18, 2023.', +) + +NEWSPAPER = Exemplar( + name="newspaper", + source="test", + type="newspaper", + tags=["newspaper"], + canary=False, + messy_input="", + canonical={ + "author": "Blum, Dani", + "article_title": "Are Flax Seeds All That?", + "newspaper": "New York Times", + "date": "December 13, 2023", + "url": "https://www.nytimes.com/2023/12/13/well/eat/flax-seeds-benefits.html", + }, + expected_bibliography='Blum, Dani. "Are Flax Seeds All That?" *New York Times*, December 13, 2023. https://www.nytimes.com/2023/12/13/well/eat/flax-seeds-benefits.html.', +) + +SOCIAL = Exemplar( + name="social_media", + source="test", + type="social_media", + tags=["social_media"], + canary=False, + messy_input="", + canonical={ + "author": "Chicago Manual of Style", + "post_text": "Is the world ready for singular they? We thought so in 1993.", + "platform": "Facebook", + "date": "April 17, 2015", + "url": "https://www.facebook.com/ChicagoManual/posts/10152906193679151", + }, + expected_bibliography='Chicago Manual of Style. "Is the world ready for singular they? We thought so in 1993." Facebook, April 17, 2015. https://www.facebook.com/ChicagoManual/posts/10152906193679151.', +) + +PODCAST = Exemplar( + name="podcast", + source="test", + type="podcast", + tags=["podcast"], + canary=False, + messy_input="", + canonical={ + "host": "Ober, Lauren, host", + "podcast": "The Loudest Girl in the World", + "season_episode": "Season 1, episode 2", + "episode_title": "Goodbye, Routine; Hello, Meltdown!", + "publisher": "Pushkin Industries", + "date": "September 13, 2022", + "format": "Podcast", + "duration": "41 min., 37 sec.", + "url": "https://www.pushkin.fm/podcasts/loudest-girl-in-the-world", + }, + expected_bibliography='Ober, Lauren, host. *The Loudest Girl in the World*. Season 1, episode 2, "Goodbye, Routine; Hello, Meltdown!" Pushkin Industries, September 13, 2022. Podcast, 41 min., 37 sec. https://www.pushkin.fm/podcasts/loudest-girl-in-the-world.', +) + +VIDEO = Exemplar( + name="video", + source="test", + type="video", + tags=["video"], + canary=False, + messy_input="", + canonical={ + "author": "Cowan, Vaitea", + "title": "How Green Hydrogen Could End the Fossil Fuel Era", + "venue": "TED Talk, Vancouver, BC", + "date": "April 2022", + "format": "Video", + "duration": "9 min., 15 sec.", + "url": "https://www.ted.com/talks/vaitea_cowan_how_green_hydrogen_could_end_the_fossil_fuel_era", + }, + expected_bibliography='Cowan, Vaitea. "How Green Hydrogen Could End the Fossil Fuel Era." TED Talk, Vancouver, BC, April 2022. Video, 9 min., 15 sec. https://www.ted.com/talks/vaitea_cowan_how_green_hydrogen_could_end_the_fossil_fuel_era.', +) + def test_linter_version_is_set(): assert LINTER_VERSION @@ -254,13 +374,229 @@ def test_web_page_title_quoted_fails_when_unquoted(): assert result.passed is False +# --- Article title rule extended to magazines and newspapers ----------------- + + +def test_article_title_quoted_passes_on_magazine(): + # Magazines follow the same article-title convention as journals. + result = rule_article_title_quoted(MAGAZINE.expected_bibliography, MAGAZINE) + assert result.applicable is True + assert result.passed is True + + +def test_article_title_quoted_passes_on_newspaper(): + result = rule_article_title_quoted(NEWSPAPER.expected_bibliography, NEWSPAPER) + assert result.applicable is True + assert result.passed is True + + +def test_article_title_quoted_not_applicable_to_book(): + result = rule_article_title_quoted(BOOK.expected_bibliography, BOOK) + assert result.applicable is False + + +# --- Chapter rules ------------------------------------------------------------ + + +def test_chapter_book_title_italicized_passes(): + # Chapters have their enclosing book's title italicized (CMOS 18, + # "Article, Chapter, Essay, etc., in a Book" model). + result = rule_chapter_book_title_italicized(CHAPTER.expected_bibliography, CHAPTER) + assert result.applicable is True + assert result.passed is True + + +def test_chapter_book_title_italicized_fails_when_plain(): + bad = CHAPTER.expected_bibliography.replace( + "*The Book by Design: The Remarkable Story of the World's Greatest Invention*", + "The Book by Design: The Remarkable Story of the World's Greatest Invention", + ) + result = rule_chapter_book_title_italicized(bad, CHAPTER) + assert result.passed is False + + +def test_chapter_book_title_italicized_not_applicable_to_book(): + result = rule_chapter_book_title_italicized(BOOK.expected_bibliography, BOOK) + assert result.applicable is False + + +def test_chapter_in_book_marker_passes(): + # Chapter entries include the keyword "In" before the italicized book + # title to distinguish them from standalone books. + result = rule_chapter_in_book_marker(CHAPTER.expected_bibliography, CHAPTER) + assert result.passed is True + + +def test_chapter_in_book_marker_fails_without_in(): + bad = CHAPTER.expected_bibliography.replace(" In *", " *") + result = rule_chapter_in_book_marker(bad, CHAPTER) + assert result.passed is False + + +# --- Magazine and newspaper name italicized ---------------------------------- + + +def test_magazine_name_italicized_passes(): + # CMOS 18: magazine titles are italicized. + result = rule_magazine_name_italicized(MAGAZINE.expected_bibliography, MAGAZINE) + assert result.passed is True + + +def test_magazine_name_italicized_fails_when_plain(): + bad = MAGAZINE.expected_bibliography.replace("*New Yorker*", "New Yorker") + result = rule_magazine_name_italicized(bad, MAGAZINE) + assert result.passed is False + + +def test_newspaper_name_italicized_passes(): + result = rule_newspaper_name_italicized(NEWSPAPER.expected_bibliography, NEWSPAPER) + assert result.passed is True + + +def test_newspaper_name_italicized_fails_when_plain(): + bad = NEWSPAPER.expected_bibliography.replace("*New York Times*", "New York Times") + result = rule_newspaper_name_italicized(bad, NEWSPAPER) + assert result.passed is False + + +# --- Periodical comma before date (magazine/newspaper only) ------------------- + + +def test_periodical_comma_before_date_passes_on_magazine(): + # Magazines and newspapers use a COMMA between the italicized name and + # the date, not a period: *New Yorker*, December 18, 2023. + result = rule_periodical_comma_before_date(MAGAZINE.expected_bibliography, MAGAZINE) + assert result.passed is True + + +def test_periodical_comma_before_date_fails_when_period_used(): + bad = MAGAZINE.expected_bibliography.replace("*New Yorker*,", "*New Yorker*.") + result = rule_periodical_comma_before_date(bad, MAGAZINE) + assert result.passed is False + + +def test_periodical_comma_before_date_passes_on_newspaper(): + result = rule_periodical_comma_before_date(NEWSPAPER.expected_bibliography, NEWSPAPER) + assert result.passed is True + + +def test_periodical_comma_before_date_not_applicable_to_journal(): + # Journals use volume/issue/year in parens, no comma-after-italic pattern. + result = rule_periodical_comma_before_date(JOURNAL.expected_bibliography, JOURNAL) + assert result.applicable is False + + +# --- Social media rules ------------------------------------------------------- + + +def test_social_media_post_quoted_passes(): + # The post content is wrapped in straight double quotes. + result = rule_social_media_post_quoted(SOCIAL.expected_bibliography, SOCIAL) + assert result.passed is True + + +def test_social_media_post_quoted_fails_when_title_cased(): + # Title-casing is a common LLM error on social media content. + bad = SOCIAL.expected_bibliography.replace( + "Is the world ready for singular they? We thought so in 1993.", + "Is the World Ready for Singular They? We Thought So in 1993.", + ) + result = rule_social_media_post_quoted(bad, SOCIAL) + assert result.passed is False + + +def test_social_media_platform_comma_date_passes(): + # The platform name is followed by a comma (not a period) before the date. + result = rule_social_media_platform_comma_date(SOCIAL.expected_bibliography, SOCIAL) + assert result.passed is True + + +def test_social_media_platform_comma_date_fails_when_period_used(): + bad = SOCIAL.expected_bibliography.replace("Facebook, April", "Facebook. April") + result = rule_social_media_platform_comma_date(bad, SOCIAL) + assert result.passed is False + + +# --- Podcast rules ------------------------------------------------------------ + + +def test_podcast_series_italicized_passes(): + # Podcast series titles are italicized like book titles. + result = rule_podcast_series_italicized(PODCAST.expected_bibliography, PODCAST) + assert result.passed is True + + +def test_podcast_series_italicized_fails_when_plain(): + bad = PODCAST.expected_bibliography.replace( + "*The Loudest Girl in the World*", "The Loudest Girl in the World" + ) + result = rule_podcast_series_italicized(bad, PODCAST) + assert result.passed is False + + +def test_podcast_episode_quoted_passes(): + # Episode titles are in quotes (including terminal punctuation like !). + result = rule_podcast_episode_quoted(PODCAST.expected_bibliography, PODCAST) + assert result.passed is True + + +def test_podcast_episode_quoted_fails_when_unquoted(): + bad = PODCAST.expected_bibliography.replace( + '"Goodbye, Routine; Hello, Meltdown!"', + "Goodbye, Routine; Hello, Meltdown!", + ) + result = rule_podcast_episode_quoted(bad, PODCAST) + assert result.passed is False + + +def test_podcast_format_label_passes(): + # CMOS includes a "Podcast," format label before duration. + result = rule_podcast_format_label(PODCAST.expected_bibliography, PODCAST) + assert result.passed is True + + +def test_podcast_format_label_fails_when_missing(): + bad = PODCAST.expected_bibliography.replace("Podcast, ", "") + result = rule_podcast_format_label(bad, PODCAST) + assert result.passed is False + + +# --- Video rules -------------------------------------------------------------- + + +def test_video_title_quoted_passes(): + # Video/TED Talk titles are in quotes (not italicized). + result = rule_video_title_quoted(VIDEO.expected_bibliography, VIDEO) + assert result.passed is True + + +def test_video_title_quoted_fails_when_italicized(): + bad = VIDEO.expected_bibliography.replace( + '"How Green Hydrogen Could End the Fossil Fuel Era."', + "*How Green Hydrogen Could End the Fossil Fuel Era.*", + ) + result = rule_video_title_quoted(bad, VIDEO) + assert result.passed is False + + +def test_video_format_label_passes(): + # CMOS includes a "Video," format label before duration. + result = rule_video_format_label(VIDEO.expected_bibliography, VIDEO) + assert result.passed is True + + +def test_video_format_label_fails_when_missing(): + bad = VIDEO.expected_bibliography.replace("Video, ", "") + result = rule_video_format_label(bad, VIDEO) + assert result.passed is False + + # --- Aggregate check_passes --------------------------------------------------- def test_check_passes_on_canonical_outputs(): - # All three seed exemplars' expected_bibliography strings must lint clean. - # If any of them fail, either the linter is too strict or the exemplar is - # wrong — either way, we want to know immediately. - assert check_passes(BOOK.expected_bibliography, BOOK) - assert check_passes(JOURNAL.expected_bibliography, JOURNAL) - assert check_passes(WEB.expected_bibliography, WEB) + # Every in-test exemplar's expected_bibliography must lint clean. + # If any fail, either the linter is too strict or the exemplar is wrong — + # either way, we want to know immediately. + for ex in (BOOK, JOURNAL, WEB, CHAPTER, MAGAZINE, NEWSPAPER, SOCIAL, PODCAST, VIDEO): + assert check_passes(ex.expected_bibliography, ex), f"linter rejected {ex.name}"