MIL-003: Step 1 - Scrape the Billboard Hot 100 #45
@@ -81,12 +81,28 @@ The program is added step by step (one pull request per lecture step):
|
||||
|
||||
| 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 |
|
||||
| 3. Create the playlist | planned in milestone 5 |
|
||||
| 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 program's output for the same date (rendered from the real output of the run, not a photo of a terminal window):
|
||||
|
||||

|
||||
|
||||
The screenshots and the instructions of the next steps are added with each step.
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| MIL | Milestone / Gateway | docs/milestones/mil-*.md | 008 |
|
||||
| GOV | Governance / ARB Workflow | docs/sqa/governance.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
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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 | [2cb7e16] |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
[2cb7e16]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/046-scraping-the-billboard/commit/2cb7e1679843ef1cd17fb612048162f661684417
|
||||
@@ -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] |
|
||||
| [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-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-005] | MIL | en | it | [MIL-004] | [MIL-006] | [RC-009] |
|
||||
| [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
|
||||
[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-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 |
@@ -19,6 +19,9 @@ dev = [
|
||||
"ruff>=0.17",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
musical-time-machine = "musical_time_machine.presentation.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
|
||||
@@ -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)]
|
||||
@@ -45,6 +45,33 @@ SELECTOR_TITLE: Final[str] = "h3.chart-entry__title"
|
||||
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
|
||||
## @{
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
+2859
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))
|
||||
@@ -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)
|
||||
@@ -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")]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user