diff --git a/README.md b/README.md index 7bbee3b..78f1c24 100644 --- a/README.md +++ b/README.md @@ -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 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 diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index d3c806e..a57af3f 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -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 diff --git a/docs/sqa/reviews/rc-013-mil-003-code.md b/docs/sqa/reviews/rc-013-mil-003-code.md new file mode 100644 index 0000000..876cd7f --- /dev/null +++ b/docs/sqa/reviews/rc-013-mil-003-code.md @@ -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/ 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 diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 1a8828a..256f80d 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -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 diff --git a/docs/wiki/images/step1-bakeboard-page.png b/docs/wiki/images/step1-bakeboard-page.png new file mode 100644 index 0000000..a842b74 Binary files /dev/null and b/docs/wiki/images/step1-bakeboard-page.png differ diff --git a/docs/wiki/images/step1-terminal-output.png b/docs/wiki/images/step1-terminal-output.png new file mode 100644 index 0000000..69fb93c Binary files /dev/null and b/docs/wiki/images/step1-terminal-output.png differ diff --git a/pyproject.toml b/pyproject.toml index c1636f4..4a3a9df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/musical_time_machine/__main__.py b/src/musical_time_machine/__main__.py new file mode 100644 index 0000000..89797ea --- /dev/null +++ b/src/musical_time_machine/__main__.py @@ -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()) diff --git a/src/musical_time_machine/application/ports.py b/src/musical_time_machine/application/ports.py new file mode 100644 index 0000000..1d48ea0 --- /dev/null +++ b/src/musical_time_machine/application/ports.py @@ -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. + """ + ... diff --git a/src/musical_time_machine/application/use_cases.py b/src/musical_time_machine/application/use_cases.py new file mode 100644 index 0000000..a6d1eaa --- /dev/null +++ b/src/musical_time_machine/application/use_cases.py @@ -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)] diff --git a/src/musical_time_machine/constants.py b/src/musical_time_machine/constants.py index c687bae..58266b1 100644 --- a/src/musical_time_machine/constants.py +++ b/src/musical_time_machine/constants.py @@ -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 ## @{ diff --git a/src/musical_time_machine/domain/chart_date.py b/src/musical_time_machine/domain/chart_date.py new file mode 100644 index 0000000..8edcd2e --- /dev/null +++ b/src/musical_time_machine/domain/chart_date.py @@ -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 diff --git a/src/musical_time_machine/domain/errors.py b/src/musical_time_machine/domain/errors.py new file mode 100644 index 0000000..6f382bb --- /dev/null +++ b/src/musical_time_machine/domain/errors.py @@ -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.""" diff --git a/src/musical_time_machine/domain/song.py b/src/musical_time_machine/domain/song.py new file mode 100644 index 0000000..4c499b9 --- /dev/null +++ b/src/musical_time_machine/domain/song.py @@ -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 diff --git a/src/musical_time_machine/infrastructure/bakeboard.py b/src/musical_time_machine/infrastructure/bakeboard.py new file mode 100644 index 0000000..21ed9ae --- /dev/null +++ b/src/musical_time_machine/infrastructure/bakeboard.py @@ -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 diff --git a/src/musical_time_machine/presentation/cli.py b/src/musical_time_machine/presentation/cli.py new file mode 100644 index 0000000..981bc41 --- /dev/null +++ b/src/musical_time_machine/presentation/cli.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b5d9eba --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/fixtures/bakeboard-2026-04-18.html b/tests/fixtures/bakeboard-2026-04-18.html new file mode 100644 index 0000000..2eba7bc --- /dev/null +++ b/tests/fixtures/bakeboard-2026-04-18.html @@ -0,0 +1,2859 @@ + + + + + + Bakeboard Hot 100 - Week of April 18, 2026 + + + + + + + + + +
+ +
+ +
+ + +
+

The Hot 100

+

Week of April 18, 2026

+ +
+
+
+ Week of April 18, 2026 + ▼ +
+
+
+
+ + +
+ + +
+
+
+ SuMoTu + WeThFrSa +
+
+
+
+
+
+ + +
+ This week's chart highlights + +
+ + +
+ + + LAST WEEK + PEAK + WKS ON CHART + +
+ + +
+ +
+
+ 1 + +
+ +
+
+ LW + 1 +
+
+ PEAK + 1 +
+
+ WKS + 25 +
+
+
+
+ +
+
+ 2 + +
+ +
+
+ LW + 3 +
+
+ PEAK + 2 +
+
+ WKS + 33 +
+
+
+
+ +
+
+ 3 + +
+ +
+
+ LW + 4 +
+
+ PEAK + 1 +
+
+ WKS + 13 +
+
+
+
+ +
+
+ 4 + +
+ +
+
+ LW + 5 +
+
+ PEAK + 1 +
+
+ WKS + 60 +
+
+
+
+ +
+
+ 5 + +
+ +
+
+ LW + 2 +
+
+ PEAK + 1 +
+
+ WKS + 3 +
+
+
+
+ +
+
+ 6 + +
+ +
+
+ LW + 6 +
+
+ PEAK + 6 +
+
+ WKS + 28 +
+
+
+
+ +
+
+ 7 + +
+ +
+
+ LW + 7 +
+
+ PEAK + 1 +
+
+ WKS + 42 +
+
+
+
+ +
+
+ 8 + +
+ +
+
+ LW + 12 +
+
+ PEAK + 8 +
+
+ WKS + 8 +
+
+
+
+ +
+
+ 9 + +
+ +
+
+ LW + 8 +
+
+ PEAK + 6 +
+
+ WKS + 14 +
+
+
+
+ +
+
+ 10 + +
+ +
+
+ LW + 10 +
+
+ PEAK + 6 +
+
+ WKS + 43 +
+
+
+
+ +
+
+ 11 + +
+ +
+
+ LW + 9 +
+
+ PEAK + 1 +
+
+ WKS + 27 +
+
+
+
+ +
+
+ 12 + +
+ +
+
+ LW + 11 +
+
+ PEAK + 11 +
+
+ WKS + 28 +
+
+
+
+ +
+
+ 13 + +
+ +
+
+ LW + 14 +
+
+ PEAK + 11 +
+
+ WKS + 13 +
+
+
+
+ +
+
+ 14 + +
+ +
+
+ LW + 15 +
+
+ PEAK + 4 +
+
+ WKS + 6 +
+
+
+
+ +
+
+ 15 + +
+ +
+
+ LW + 13 +
+
+ PEAK + 1 +
+
+ WKS + 27 +
+
+
+
+ +
+
+ 16 + +
+ +
+
+ LW + 16 +
+
+ PEAK + 16 +
+
+ WKS + 9 +
+
+
+
+ +
+
+ 17 + +
+ +
+
+ LW + 18 +
+
+ PEAK + 17 +
+
+ WKS + 28 +
+
+
+
+ +
+
+ 18 + +
+ +
+
+ LW + 17 +
+
+ PEAK + 17 +
+
+ WKS + 8 +
+
+
+
+ +
+
+ 19 + +
+ +
+
+ LW + 19 +
+
+ PEAK + 15 +
+
+ WKS + 10 +
+
+
+
+ +
+
+ 20 + +
+ +
+
+ LW + 20 +
+
+ PEAK + 17 +
+
+ WKS + 39 +
+
+
+
+ +
+
+ 21 + +
+ +
+
+ LW + 22 +
+
+ PEAK + 12 +
+
+ WKS + 8 +
+
+
+
+ +
+
+ 22 + +
+ +
+
+ LW + 25 +
+
+ PEAK + 22 +
+
+ WKS + 9 +
+
+
+
+ +
+
+ 23 + +
+ +
+
+ LW + 24 +
+
+ PEAK + 1 +
+
+ WKS + 30 +
+
+
+
+ +
+
+ 24 + +
+ +
+
+ LW + 23 +
+
+ PEAK + 4 +
+
+ WKS + 5 +
+
+
+
+ +
+
+ 25 + +
+ +
+
+ LW + 26 +
+
+ PEAK + 14 +
+
+ WKS + 10 +
+
+
+
+ +
+
+ 26 + +
+ +
+
+ LW + 28 +
+
+ PEAK + 26 +
+
+ WKS + 28 +
+
+
+
+ +
+
+ 27 + +
+ +
+
+ LW + 30 +
+
+ PEAK + 27 +
+
+ WKS + 10 +
+
+
+
+ +
+
+ 28 + +
+ +
+
+ LW + 29 +
+
+ PEAK + 19 +
+
+ WKS + 16 +
+
+
+
+ +
+
+ 29 + +
+ +
+
+ LW + 31 +
+
+ PEAK + 29 +
+
+ WKS + 10 +
+
+
+
+ +
+
+ 30 + +
+ +
+
+ LW + 56 +
+
+ PEAK + 8 +
+
+ WKS + 43 +
+
+
+
+ +
+
+ 31 + +
+ +
+
+ LW + 21 +
+
+ PEAK + 21 +
+
+ WKS + 2 +
+
+
+
+ +
+
+ 32 + +
+ +
+
+ LW + 33 +
+
+ PEAK + 21 +
+
+ WKS + 6 +
+
+
+
+ +
+
+ 33 + +
+ +
+
+ LW + 34 +
+
+ PEAK + 33 +
+
+ WKS + 11 +
+
+
+
+ +
+
+ 34 + +
+ +
+
+ LW + 32 +
+
+ PEAK + 32 +
+
+ WKS + 7 +
+
+
+
+ +
+
+ 35 + +
+ +
+
+ LW + 36 +
+
+ PEAK + 35 +
+
+ WKS + 17 +
+
+
+
+ +
+
+ 36 + +
+ +
+
+ LW + 45 +
+
+ PEAK + 36 +
+
+ WKS + 15 +
+
+
+
+ +
+
+ 37 + +
+ +
+
+ LW + 27 +
+
+ PEAK + 27 +
+
+ WKS + 2 +
+
+
+
+ +
+
+ 38 + +
+ +
+
+ LW + 46 +
+
+ PEAK + 38 +
+
+ WKS + 3 +
+
+
+
+ +
+
+ 39 + +
+ +
+
+ LW + 38 +
+
+ PEAK + 6 +
+
+ WKS + 10 +
+
+
+
+ +
+
+ 40 + +
+ +
+
+ LW + 39 +
+
+ PEAK + 31 +
+
+ WKS + 24 +
+
+
+
+ +
+
+ 41 + +
+ +
+
+ LW + 37 +
+
+ PEAK + 12 +
+
+ WKS + 15 +
+
+
+
+ +
+
+ 42 + +
+ +
+
+ LW + 65 +
+
+ PEAK + 42 +
+
+ WKS + 6 +
+
+
+
+ +
+
+ 43 + +
+ +
+
+ LW + 43 +
+
+ PEAK + 39 +
+
+ WKS + 12 +
+
+
+
+ +
+
+ 44 + +
+ +
+
+ LW + 41 +
+
+ PEAK + 41 +
+
+ WKS + 4 +
+
+
+
+ +
+
+ 45 + +
+ +
+
+ LW + 35 +
+
+ PEAK + 3 +
+
+ WKS + 23 +
+
+
+
+ +
+
+ 46 + +
+ +
+
+ LW + 54 +
+
+ PEAK + 37 +
+
+ WKS + 10 +
+
+
+
+ +
+
+ 47 + +
+ +
+
+ LW + 50 +
+
+ PEAK + 17 +
+
+ WKS + 30 +
+
+
+
+ +
+
+ 48 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 12 +
+
+ WKS + 16 +
+
+
+
+ +
+
+ 49 + +
+ +
+
+ LW + 52 +
+
+ PEAK + 49 +
+
+ WKS + 10 +
+
+
+
+ +
+
+ 50 + +
+ +
+
+ LW + 48 +
+
+ PEAK + 20 +
+
+ WKS + 4 +
+
+
+
+ +
+
+ 51 + +
+ +
+
+ LW + 77 +
+
+ PEAK + 51 +
+
+ WKS + 3 +
+
+
+
+ +
+
+ 52 + +
+ +
+
+ LW + 42 +
+
+ PEAK + 25 +
+
+ WKS + 3 +
+
+
+
+ +
+
+ 53 + +
+ +
+
+ LW + 68 +
+
+ PEAK + 53 +
+
+ WKS + 10 +
+
+
+
+ +
+
+ 54 + +
+ +
+
+ LW + 47 +
+
+ PEAK + 31 +
+
+ WKS + 10 +
+
+
+
+ +
+
+ 55 + +
+ +
+
+ LW + 66 +
+
+ PEAK + 55 +
+
+ WKS + 6 +
+
+
+
+ +
+
+ 56 + +
+ +
+
+ LW + 89 +
+
+ PEAK + 56 +
+
+ WKS + 4 +
+
+
+
+ +
+
+ 57 + +
+ +
+
+ LW + 60 +
+
+ PEAK + 57 +
+
+ WKS + 5 +
+
+
+
+ +
+
+ 58 + +
+ +
+
+ LW + 53 +
+
+ PEAK + 43 +
+
+ WKS + 17 +
+
+
+
+ +
+
+ 59 + +
+ +
+
+ LW + 71 +
+
+ PEAK + 32 +
+
+ WKS + 19 +
+
+
+
+ +
+
+ 60 + +
+ +
+
+ LW + 70 +
+
+ PEAK + 60 +
+
+ WKS + 6 +
+
+
+
+ +
+
+ 61 + +
+ +
+
+ LW + 88 +
+
+ PEAK + 55 +
+
+ WKS + 4 +
+
+
+
+ +
+
+ 62 + +
+ +
+
+ LW + 86 +
+
+ PEAK + 48 +
+
+ WKS + 9 +
+
+
+
+ +
+
+ 63 + +
+ +
+
+ LW + 83 +
+
+ PEAK + 63 +
+
+ WKS + 3 +
+
+
+
+ +
+
+ 64 + +
+ +
+
+ LW + 78 +
+
+ PEAK + 64 +
+
+ WKS + 4 +
+
+
+
+ +
+
+ 65 + +
+ +
+
+ LW + 55 +
+
+ PEAK + 46 +
+
+ WKS + 3 +
+
+
+
+ +
+
+ 66 + +
+ +
+
+ LW + 58 +
+
+ PEAK + 1 +
+
+ WKS + 11 +
+
+
+
+ +
+
+ 67 + +
+ +
+
+ LW + 75 +
+
+ PEAK + 50 +
+
+ WKS + 3 +
+
+
+
+ +
+
+ 68 + +
+ +
+
+ LW + 63 +
+
+ PEAK + 63 +
+
+ WKS + 8 +
+
+
+
+ +
+
+ 69 + +
+ +
+
+ LW + 80 +
+
+ PEAK + 43 +
+
+ WKS + 4 +
+
+
+
+ +
+
+ 70 + +
+ +
+
+ LW + 93 +
+
+ PEAK + 52 +
+
+ WKS + 14 +
+
+
+
+ +
+
+ 71 + +
+ +
+
+ LW + 44 +
+
+ PEAK + 44 +
+
+ WKS + 2 +
+
+
+
+ +
+
+ 72 + +
+ +
+
+ LW + 64 +
+
+ PEAK + 35 +
+
+ WKS + 3 +
+
+
+
+ +
+
+ 73 + +
+ +
+
+ LW + 95 +
+
+ PEAK + 64 +
+
+ WKS + 8 +
+
+
+
+ +
+
+ 74 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 74 +
+
+ WKS + 1 +
+
+
+
+ +
+
+ 75 + +
+ +
+
+ LW + 92 +
+
+ PEAK + 75 +
+
+ WKS + 7 +
+
+
+
+ +
+
+ 76 + +
+ +
+
+ LW + 76 +
+
+ PEAK + 72 +
+
+ WKS + 12 +
+
+
+
+ +
+
+ 77 + +
+ +
+
+ LW + 91 +
+
+ PEAK + 73 +
+
+ WKS + 9 +
+
+
+
+ +
+
+ 78 + +
+ +
+
+ LW + 49 +
+
+ PEAK + 49 +
+
+ WKS + 2 +
+
+
+
+ +
+
+ 79 + +
+ +
+
+ LW + 40 +
+
+ PEAK + 40 +
+
+ WKS + 2 +
+
+
+
+ +
+
+ 80 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 25 +
+
+ WKS + 12 +
+
+
+
+ +
+
+ 81 + +
+ +
+
+ LW + 79 +
+
+ PEAK + 18 +
+
+ WKS + 5 +
+
+
+
+ +
+
+ 82 + +
+ +
+
+ LW + 73 +
+
+ PEAK + 41 +
+
+ WKS + 3 +
+
+
+
+ +
+
+ 83 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 83 +
+
+ WKS + 3 +
+
+
+
+ +
+
+ 84 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 84 +
+
+ WKS + 3 +
+
+
+
+ +
+
+ 85 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 60 +
+
+ WKS + 2 +
+
+
+
+ +
+
+ 86 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 35 +
+
+ WKS + 10 +
+
+
+
+ +
+
+ 87 + +
+ +
+
+ LW + 100 +
+
+ PEAK + 13 +
+
+ WKS + 12 +
+
+
+
+ +
+
+ 88 + +
+ +
+
+ LW + 98 +
+
+ PEAK + 88 +
+
+ WKS + 2 +
+
+
+
+ +
+
+ 89 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 89 +
+
+ WKS + 1 +
+
+
+
+ +
+
+ 90 + +
+ +
+
+ LW + 74 +
+
+ PEAK + 36 +
+
+ WKS + 3 +
+
+
+
+ +
+
+ 91 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 91 +
+
+ WKS + 1 +
+
+
+
+ +
+
+ 92 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 92 +
+
+ WKS + 1 +
+
+
+
+ +
+
+ 93 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 48 +
+
+ WKS + 7 +
+
+
+
+ +
+
+ 94 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 94 +
+
+ WKS + 1 +
+
+
+
+ +
+
+ 95 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 95 +
+
+ WKS + 1 +
+
+
+
+ +
+
+ 96 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 82 +
+
+ WKS + 2 +
+
+
+
+ +
+
+ 97 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 29 +
+
+ WKS + 9 +
+
+
+
+ +
+
+ 98 + +
+ +
+
+ LW + 51 +
+
+ PEAK + 51 +
+
+ WKS + 2 +
+
+
+
+ +
+
+ 99 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 99 +
+
+ WKS + 1 +
+
+
+
+ +
+
+ 100 + NEW +
+ +
+
+ LW + - +
+
+ PEAK + 100 +
+
+ WKS + 1 +
+
+
+
+ +
+ + +
+

This is a practice website for educational purposes only. Not affiliated + with Billboard or Luminate. Song data sourced from public datasets.

+
+ +
+ + + + + + + + \ No newline at end of file diff --git a/tests/integration/test_bakeboard_chart_source.py b/tests/integration/test_bakeboard_chart_source.py new file mode 100644 index 0000000..3235e64 --- /dev/null +++ b/tests/integration/test_bakeboard_chart_source.py @@ -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: "") + + 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)) diff --git a/tests/unit/application/test_use_cases.py b/tests/unit/application/test_use_cases.py new file mode 100644 index 0000000..be3044a --- /dev/null +++ b/tests/unit/application/test_use_cases.py @@ -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) diff --git a/tests/unit/domain/test_chart_date.py b/tests/unit/domain/test_chart_date.py new file mode 100644 index 0000000..06f853a --- /dev/null +++ b/tests/unit/domain/test_chart_date.py @@ -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") diff --git a/tests/unit/infrastructure/test_bakeboard_parser.py b/tests/unit/infrastructure/test_bakeboard_parser.py new file mode 100644 index 0000000..3f27f7b --- /dev/null +++ b/tests/unit/infrastructure/test_bakeboard_parser.py @@ -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("

nothing

") + + +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 = ( + '

Lonely

' + '

A

' + ' B
' + ) + + songs = parse_chart(html) + + assert [(s.title, s.artist) for s in songs] == [("A", "B")] diff --git a/tests/unit/presentation/test_cli.py b/tests/unit/presentation/test_cli.py new file mode 100644 index 0000000..ca7dd7c --- /dev/null +++ b/tests/unit/presentation/test_cli.py @@ -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"]