MIL-003: Step 1 - Scrape the Billboard Hot 100 #45

Merged
Tirsvad merged 2 commits from mil-003-step-1-scrape-billboard into main 2026-10-28 07:26:42 +01:00
23 changed files with 3471 additions and 4 deletions
Showing only changes of commit 2cb7e16798 - Show all commits
+18 -2
View File
@@ -81,12 +81,28 @@ The program is added step by step (one pull request per lecture step):
| Step | Status | | Step | Status |
| --- | --- | | --- | --- |
| 1. Scrape the Billboard Hot 100 | planned in milestone 3 | | 1. Scrape the Billboard Hot 100 | done (below) |
| 2. YouTube Music authentication | planned in milestone 4 | | 2. YouTube Music authentication | planned in milestone 4 |
| 3. Create the playlist | planned in milestone 5 | | 3. Create the playlist | planned in milestone 5 |
| 4. Search and add songs | planned in milestone 6 | | 4. Search and add songs | planned in milestone 6 |
The run command and a screenshot for each step are added with the step. ### Step 1 - Scrape the Billboard Hot 100
```bash
python -m musical_time_machine
```
The program asks for a date in the format `YYYY-MM-DD` and prints the 100 songs of that chart as `rank. title - artist`. Use a date that Bakeboard has, for example `2026-04-18`. `scrape_titles` returns the titles as a Python list; a date in the wrong format or a chart that does not exist ends the program with a clear message.
The chart page on Bakeboard:
![The Bakeboard Hot 100 page for 2026-04-18](docs/wiki/images/step1-bakeboard-page.png)
The program's output for the same date (rendered from the real output of the run, not a photo of a terminal window):
![Output of step 1 for 2026-04-18](docs/wiki/images/step1-terminal-output.png)
The screenshots and the instructions of the next steps are added with each step.
## 🧪 Tests ## 🧪 Tests
+1 -1
View File
@@ -17,7 +17,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| MIL | Milestone / Gateway | docs/milestones/mil-*.md | 008 | | MIL | Milestone / Gateway | docs/milestones/mil-*.md | 008 |
| GOV | Governance / ARB Workflow | docs/sqa/governance.md | 002 | | GOV | Governance / ARB Workflow | docs/sqa/governance.md | 002 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 013 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 014 |
## Languages ## Languages
+61
View File
@@ -0,0 +1,61 @@
# Review of the code of MIL-003
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-013 |
| CrossReference | [MIL-003], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending |
---
## Artifact Under Review
- Instance reviewed: [MIL-003] (the code of its pull request, `src/` and `tests/`)
- Checklist used: [QC-PY-001] (`QC-PY-001`)
- Scope: full review
- Language and domain: n/a
- Language reviewer: none
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | snake_case modules and functions, PascalCase classes (Song, BakeboardChartSource), UPPER_SNAKE constants. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (parse_chart_date, ChartUnavailableError). |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | ruff format and ruff check report no finding. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every function is annotated including -> None; ruff does not flag it and the code was read. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except; requests errors are translated with raise ... from. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults; no shadowed builtins (the CLI parameter `read`/`write` do not shadow builtins). |
| 7 | Files, locks and connections are managed with context managers | N-A | No files or connections are opened by the code; requests.get is a one-shot call. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Doxygen comments on all public items; doxygen has 0 warnings. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | logging is used for the skipped-row warning; user-facing output goes through the injected writer; nothing secret is logged. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists; all tasks are technical (BC-001 scope); the layers follow the clean-architecture skill. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 31 tests, 96 % coverage, no network: a stored page and fake sources; names state the behaviour. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | No Any in the source. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | requests and beautifulsoup4 are used by infrastructure; ytmusicapi is used from MIL-004 (declared now by the accepted plan). |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | Selectors, messages and the date format are defined once in constants.py; the date is parsed in one function. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | domain imports nothing; application imports domain; infrastructure imports application and domain; presentation is the composition root. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | ChartSource is a narrow Protocol; the CLI depends on it, the Bakeboard source is injected. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | src layers and tests/unit/<layer> plus tests/integration exist. |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File names are English snake_case. |
## Overall Verdict
Go — every Mandatory criterion passes or does not apply; no blocking finding.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | - |
---
[MIL-003]: ../../milestones/mil-003-step-1-scrape-billboard.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
+2 -1
View File
@@ -27,7 +27,7 @@ Tracks backward and forward links between artifact instances so that the traceab
| [GOV-001] | GOV | en | it | [BC-001], [SA-001] | [TM-001] | [RC-004] | | [GOV-001] | GOV | en | it | [BC-001], [SA-001] | [TM-001] | [RC-004] |
| [MIL-001] | MIL | en | it | [BC-001], [PP-001] | [MIL-002] | [RC-005] | | [MIL-001] | MIL | en | it | [BC-001], [PP-001] | [MIL-002] | [RC-005] |
| [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-006], [RC-012] | | [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-006], [RC-012] |
| [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-007] | | [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-007], [RC-013] |
| [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-008] | | [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-008] |
| [MIL-005] | MIL | en | it | [MIL-004] | [MIL-006] | [RC-009] | | [MIL-005] | MIL | en | it | [MIL-004] | [MIL-006] | [RC-009] |
| [MIL-006] | MIL | en | it | [MIL-005] | [MIL-007] | [RC-010] | | [MIL-006] | MIL | en | it | [MIL-005] | [MIL-007] | [RC-010] |
@@ -82,3 +82,4 @@ Tracks backward and forward links between artifact instances so that the traceab
[RC-011]: ./reviews/rc-011-mil-007.md [RC-011]: ./reviews/rc-011-mil-007.md
[01e016e]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/046-scraping-the-billboard/commit/01e016e15dd93cface4f4ae9e9e99919e0aace2b [01e016e]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/046-scraping-the-billboard/commit/01e016e15dd93cface4f4ae9e9e99919e0aace2b
[RC-012]: ./reviews/rc-012-mil-002-code.md [RC-012]: ./reviews/rc-012-mil-002-code.md
[RC-013]: ./reviews/rc-013-mil-003-code.md
Binary file not shown.

After

Width:  |  Height:  |  Size: 928 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

+3
View File
@@ -19,6 +19,9 @@ dev = [
"ruff>=0.17", "ruff>=0.17",
] ]
[project.scripts]
musical-time-machine = "musical_time_machine.presentation.cli:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+7
View File
@@ -0,0 +1,7 @@
"""@file
@brief Entry point for `python -m musical_time_machine`.
"""
from musical_time_machine.presentation.cli import main
raise SystemExit(main())
@@ -0,0 +1,20 @@
"""@file
@brief Ports the use cases need from the outside world.
"""
from datetime import date
from typing import Protocol
from musical_time_machine.domain.song import Song
class ChartSource(Protocol):
"""@brief Supplies the songs of one chart."""
def songs_for(self, chart_date: date) -> list[Song]:
"""@brief Get the songs of the chart of a date.
@param chart_date Date of the chart.
@return The songs in chart order.
"""
...
@@ -0,0 +1,28 @@
"""@file
@brief Use cases of the Musical Time Machine.
"""
from datetime import date
from musical_time_machine.application.ports import ChartSource
from musical_time_machine.domain.song import Song
def scrape_songs(chart_date: date, source: ChartSource) -> list[Song]:
"""@brief Get the songs of the chart of a date.
@param chart_date Date of the chart.
@param source Where the chart comes from.
@return The songs in chart order.
"""
return source.songs_for(chart_date)
def scrape_titles(chart_date: date, source: ChartSource) -> list[str]:
"""@brief Get the song titles of the chart of a date.
@param chart_date Date of the chart.
@param source Where the chart comes from.
@return The titles in chart order.
"""
return [song.title for song in scrape_songs(chart_date, source)]
+27
View File
@@ -45,6 +45,33 @@ SELECTOR_TITLE: Final[str] = "h3.chart-entry__title"
SELECTOR_ARTIST: Final[str] = "span.chart-entry__artist" SELECTOR_ARTIST: Final[str] = "span.chart-entry__artist"
## @} ## @}
## @name Command line
## @{
## @brief Prompt asking for the chart date.
DATE_PROMPT: Final[str] = (
"Which year do you want to travel to? Type the date in this format YYYY-MM-DD: "
)
## @brief Message for a date that is not YYYY-MM-DD; format with the entered text.
INVALID_DATE_MESSAGE: Final[str] = "'{text}' is not a valid date; use the format YYYY-MM-DD."
## @brief Message when the chart page cannot be fetched; format with the cause.
CHART_UNAVAILABLE_MESSAGE: Final[str] = "The chart could not be fetched: {reason}"
## @brief Message when the page holds no songs; format with the chart date.
CHART_PARSE_MESSAGE: Final[str] = "No songs were found on the chart page for {date}."
## @brief Line format of the printed song list.
SONG_LINE_TEMPLATE: Final[str] = "{position}. {title} - {artist}"
## @brief Process exit code on success.
EXIT_OK: Final[int] = 0
## @brief Process exit code when a domain error stopped the run.
EXIT_FAILURE: Final[int] = 1
## @}
## @name YouTube Music ## @name YouTube Music
## @{ ## @{
@@ -0,0 +1,26 @@
"""@file
@brief Validation of the chart date.
"""
from datetime import date, datetime
from musical_time_machine.constants import DATE_FORMAT, INVALID_DATE_MESSAGE
from musical_time_machine.domain.errors import InvalidChartDateError
def parse_chart_date(text: str) -> date:
"""@brief Turn YYYY-MM-DD text into a date.
@param text The text the user entered.
@return The calendar date.
@throws InvalidChartDateError when the text is not a real date in that format.
"""
cleaned = text.strip()
try:
parsed = datetime.strptime(cleaned, DATE_FORMAT).date()
except ValueError as error:
raise InvalidChartDateError(INVALID_DATE_MESSAGE.format(text=cleaned)) from error
if parsed.strftime(DATE_FORMAT) != cleaned:
# strptime accepts "2026-4-8"; the contract is the zero-padded form.
raise InvalidChartDateError(INVALID_DATE_MESSAGE.format(text=cleaned))
return parsed
+19
View File
@@ -0,0 +1,19 @@
"""@file
@brief Errors raised by the business rules of the Musical Time Machine.
"""
class MusicalTimeMachineError(Exception):
"""@brief Base class of every domain error."""
class InvalidChartDateError(MusicalTimeMachineError):
"""@brief The entered text is not a valid YYYY-MM-DD date."""
class ChartUnavailableError(MusicalTimeMachineError):
"""@brief The chart page could not be fetched (missing page or HTTP error)."""
class ChartParseError(MusicalTimeMachineError):
"""@brief The chart page holds no songs."""
+17
View File
@@ -0,0 +1,17 @@
"""@file
@brief The Song value object.
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Song:
"""@brief One chart entry.
@param title Song title.
@param artist Performing artist.
"""
title: str
artist: str
@@ -0,0 +1,88 @@
"""@file
@brief Bakeboard Hot 100 clone as a chart source.
"""
import logging
from collections.abc import Callable
from datetime import date
import requests
from bs4 import BeautifulSoup
from musical_time_machine.constants import (
BASE_URL,
CHART_PARSE_MESSAGE,
CHART_UNAVAILABLE_MESSAGE,
DATE_FORMAT,
REQUEST_HEADERS,
REQUEST_TIMEOUT_SECONDS,
SELECTOR_ARTIST,
SELECTOR_ROW,
SELECTOR_TITLE,
)
from musical_time_machine.domain.errors import ChartParseError, ChartUnavailableError
from musical_time_machine.domain.song import Song
_logger = logging.getLogger(__name__)
def parse_chart(html: str) -> list[Song]:
"""@brief Extract the songs from a chart page.
@param html The page markup.
@return The songs in page order.
@throws ChartParseError when the page holds no songs.
"""
soup = BeautifulSoup(html, "html.parser")
songs: list[Song] = []
for row in soup.select(SELECTOR_ROW):
title = row.select_one(SELECTOR_TITLE)
artist = row.select_one(SELECTOR_ARTIST)
if title is None or artist is None:
_logger.warning("Skipping a chart row without title or artist")
continue
songs.append(Song(title=title.get_text(strip=True), artist=artist.get_text(strip=True)))
if not songs:
raise ChartParseError("No songs found on the chart page")
return songs
def fetch_page(url: str) -> str:
"""@brief Download a page over HTTP.
@param url Address of the page.
@return The page markup.
@throws requests.RequestException when the request fails or answers with an HTTP error.
"""
response = requests.get(url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT_SECONDS)
response.raise_for_status()
return response.text
class BakeboardChartSource:
"""@brief ChartSource that scrapes the Bakeboard site."""
def __init__(self, fetch: Callable[[str], str] = fetch_page) -> None:
"""@brief Create the source.
@param fetch Function that downloads a URL; replaced in tests.
"""
self._fetch = fetch
def songs_for(self, chart_date: date) -> list[Song]:
"""@brief Get the songs of the chart of a date.
@param chart_date Date of the chart.
@return The songs in chart order.
@throws ChartUnavailableError when the page cannot be fetched.
@throws ChartParseError when the page holds no songs.
"""
stamp = chart_date.strftime(DATE_FORMAT)
try:
html = self._fetch(f"{BASE_URL}{stamp}/")
except requests.RequestException as error:
raise ChartUnavailableError(CHART_UNAVAILABLE_MESSAGE.format(reason=error)) from error
try:
return parse_chart(html)
except ChartParseError as error:
raise ChartParseError(CHART_PARSE_MESSAGE.format(date=stamp)) from error
@@ -0,0 +1,40 @@
"""@file
@brief Command line entry point and composition root.
"""
from collections.abc import Callable
from musical_time_machine.application.ports import ChartSource
from musical_time_machine.application.use_cases import scrape_songs
from musical_time_machine.constants import DATE_PROMPT, EXIT_FAILURE, EXIT_OK, SONG_LINE_TEMPLATE
from musical_time_machine.domain.chart_date import parse_chart_date
from musical_time_machine.domain.errors import MusicalTimeMachineError
from musical_time_machine.infrastructure.bakeboard import BakeboardChartSource
def main(
argv: list[str] | None = None,
*,
source: ChartSource | None = None,
read: Callable[[str], str] = input,
write: Callable[[str], None] = print,
) -> int:
"""@brief Ask for a date and print the songs of that chart.
@param argv Command line arguments; unused, kept for the entry point contract.
@param source Chart source; the Bakeboard site when omitted.
@param read Function that prompts and returns the user's answer.
@param write Function that shows one line to the user.
@return Process exit code: 0 on success, 1 when a domain error stopped the run.
"""
del argv
chart_source = source if source is not None else BakeboardChartSource()
try:
chart_date = parse_chart_date(read(DATE_PROMPT))
songs = scrape_songs(chart_date, chart_source)
except MusicalTimeMachineError as error:
write(str(error))
return EXIT_FAILURE
for position, song in enumerate(songs, start=1):
write(SONG_LINE_TEMPLATE.format(position=position, title=song.title, artist=song.artist))
return EXIT_OK
+33
View File
@@ -0,0 +1,33 @@
"""@file
@brief Shared fixtures: the stored Bakeboard page and a fake chart source.
"""
from datetime import date
from pathlib import Path
import pytest
from musical_time_machine.domain.song import Song
FIXTURE_PAGE = Path(__file__).parent / "fixtures" / "bakeboard-2026-04-18.html"
@pytest.fixture
def chart_html() -> str:
"""@brief The stored 2026-04-18 chart page."""
return FIXTURE_PAGE.read_text(encoding="utf-8")
class FakeChartSource:
"""@brief ChartSource returning fixed songs, or raising a given error."""
def __init__(self, songs: list[Song] | None = None, error: Exception | None = None) -> None:
self._songs = songs or []
self._error = error
self.requested: list[date] = []
def songs_for(self, chart_date: date) -> list[Song]:
self.requested.append(chart_date)
if self._error is not None:
raise self._error
return self._songs
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
"""@file
@brief Integration tests of BakeboardChartSource with an injected fetch (no network).
"""
from datetime import date
import pytest
import requests
from musical_time_machine.constants import BASE_URL, CHART_SIZE
from musical_time_machine.domain.errors import ChartParseError, ChartUnavailableError
from musical_time_machine.infrastructure.bakeboard import BakeboardChartSource
def test_songs_for_requests_dated_url_and_parses_fixture(chart_html: str) -> None:
urls: list[str] = []
def fetch(url: str) -> str:
urls.append(url)
return chart_html
songs = BakeboardChartSource(fetch).songs_for(date(2026, 4, 18))
assert urls == [f"{BASE_URL}2026-04-18/"]
assert len(songs) == CHART_SIZE
def test_songs_for_maps_connection_error_to_chart_unavailable() -> None:
def fetch(url: str) -> str:
raise requests.ConnectionError("offline")
with pytest.raises(ChartUnavailableError):
BakeboardChartSource(fetch).songs_for(date(2026, 4, 18))
def test_songs_for_raises_parse_error_for_empty_page_naming_the_date() -> None:
source = BakeboardChartSource(lambda url: "<html></html>")
with pytest.raises(ChartParseError, match="2026-04-18"):
source.songs_for(date(2026, 4, 18))
def test_songs_for_maps_http_error_to_chart_unavailable() -> None:
def fetch(url: str) -> str:
raise requests.HTTPError("404 Not Found")
with pytest.raises(ChartUnavailableError, match="404"):
BakeboardChartSource(fetch).songs_for(date(2026, 4, 18))
+32
View File
@@ -0,0 +1,32 @@
"""@file
@brief Tests of the scrape use cases with a fake chart source.
"""
from datetime import date
import pytest
from conftest import FakeChartSource
from musical_time_machine.application.use_cases import scrape_songs, scrape_titles
from musical_time_machine.domain.errors import ChartUnavailableError
from musical_time_machine.domain.song import Song
SONGS = [Song("One", "A"), Song("Two", "B")]
def test_scrape_songs_returns_source_songs_for_the_date() -> None:
source = FakeChartSource(SONGS)
assert scrape_songs(date(2026, 4, 18), source) == SONGS
assert source.requested == [date(2026, 4, 18)]
def test_scrape_titles_returns_titles_in_order() -> None:
assert scrape_titles(date(2026, 4, 18), FakeChartSource(SONGS)) == ["One", "Two"]
def test_scrape_titles_propagates_source_error() -> None:
source = FakeChartSource(error=ChartUnavailableError("down"))
with pytest.raises(ChartUnavailableError):
scrape_titles(date(2026, 4, 18), source)
+38
View File
@@ -0,0 +1,38 @@
"""@file
@brief Tests of the chart date validation.
"""
from datetime import date
import pytest
from musical_time_machine.domain.chart_date import parse_chart_date
from musical_time_machine.domain.errors import InvalidChartDateError
def test_parse_chart_date_returns_date_for_valid_text() -> None:
assert parse_chart_date("2026-04-18") == date(2026, 4, 18)
def test_parse_chart_date_accepts_leap_day_and_surrounding_whitespace() -> None:
assert parse_chart_date(" 2024-02-29\n") == date(2024, 2, 29)
@pytest.mark.parametrize(
"text",
["18-04-2026", "2026/04/18", "2026-4-8", "yesterday", "", " "],
)
def test_parse_chart_date_rejects_bad_format(text: str) -> None:
with pytest.raises(InvalidChartDateError):
parse_chart_date(text)
@pytest.mark.parametrize("text", ["2026-02-30", "2025-02-29", "2026-13-01"])
def test_parse_chart_date_rejects_impossible_date(text: str) -> None:
with pytest.raises(InvalidChartDateError):
parse_chart_date(text)
def test_parse_chart_date_message_names_the_input_and_format() -> None:
with pytest.raises(InvalidChartDateError, match=r"'nope'.*YYYY-MM-DD"):
parse_chart_date("nope")
@@ -0,0 +1,45 @@
"""@file
@brief Tests of the Bakeboard page parser.
"""
import pytest
from musical_time_machine.constants import CHART_SIZE
from musical_time_machine.domain.errors import ChartParseError
from musical_time_machine.infrastructure.bakeboard import parse_chart
def test_parse_chart_returns_one_hundred_songs_from_fixture(chart_html: str) -> None:
songs = parse_chart(chart_html)
assert len(songs) == CHART_SIZE
assert all(song.title and song.artist for song in songs)
def test_parse_chart_strips_whitespace_and_keeps_order(chart_html: str) -> None:
first = parse_chart(chart_html)[0]
assert first.title == "Choosin' Texas"
assert first.artist == "Ella Langley"
def test_parse_chart_raises_for_page_without_songs() -> None:
with pytest.raises(ChartParseError):
parse_chart("<html><body><p>nothing</p></body></html>")
def test_parse_chart_raises_for_empty_text() -> None:
with pytest.raises(ChartParseError):
parse_chart("")
def test_parse_chart_skips_row_without_artist() -> None:
html = (
'<div class="chart-entry"><h3 class="chart-entry__title">Lonely</h3></div>'
'<div class="chart-entry"><h3 class="chart-entry__title"> A </h3>'
'<span class="chart-entry__artist"> B </span></div>'
)
songs = parse_chart(html)
assert [(s.title, s.artist) for s in songs] == [("A", "B")]
+59
View File
@@ -0,0 +1,59 @@
"""@file
@brief Tests of the command line with a fake source and injected input and output.
"""
from conftest import FakeChartSource
from musical_time_machine.constants import CHART_SIZE, DATE_PROMPT
from musical_time_machine.domain.errors import ChartParseError, ChartUnavailableError
from musical_time_machine.domain.song import Song
from musical_time_machine.presentation.cli import main
def _run(answer: str, source: FakeChartSource) -> tuple[int, list[str], list[str]]:
prompts: list[str] = []
lines: list[str] = []
def read(prompt: str) -> str:
prompts.append(prompt)
return answer
code = main([], source=source, read=read, write=lines.append)
return code, lines, prompts
def test_main_prints_numbered_songs_and_returns_zero() -> None:
songs = [Song(f"Title {n}", f"Artist {n}") for n in range(1, CHART_SIZE + 1)]
code, lines, prompts = _run("2026-04-18", FakeChartSource(songs))
assert code == 0
assert prompts == [DATE_PROMPT]
assert len(lines) == CHART_SIZE
assert lines[0] == "1. Title 1 - Artist 1"
assert lines[-1] == "100. Title 100 - Artist 100"
def test_main_returns_one_with_message_for_invalid_date() -> None:
source = FakeChartSource([Song("x", "y")])
code, lines, _ = _run("18/04/2026", source)
assert code == 1
assert len(lines) == 1
assert "YYYY-MM-DD" in lines[0]
assert source.requested == []
def test_main_returns_one_when_chart_unavailable() -> None:
code, lines, _ = _run("2026-04-18", FakeChartSource(error=ChartUnavailableError("404")))
assert code == 1
assert lines == ["404"]
def test_main_returns_one_when_chart_has_no_songs() -> None:
code, lines, _ = _run("2026-04-18", FakeChartSource(error=ChartParseError("empty")))
assert code == 1
assert lines == ["empty"]