From 27a060650a64184c9393f0cb9b9edd68fa8340c8 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:32:35 +0800 Subject: [PATCH 1/2] Add step 3: create the private playlist without duplicates Task: MIL-005#1 --- README.md | 14 +++- docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-015-mil-005-code.md | 61 +++++++++++++++++ docs/sqa/traceability-matrix.md | 3 +- docs/wiki/images/step3-create-playlist.png | Bin 0 -> 54370 bytes src/musical_time_machine/application/ports.py | 10 +++ .../application/use_cases.py | 32 +++++++++ src/musical_time_machine/constants.py | 21 ++++++ src/musical_time_machine/domain/errors.py | 4 ++ src/musical_time_machine/domain/playlist.py | 25 +++++++ .../infrastructure/ytmusic.py | 36 +++++++++- src/musical_time_machine/presentation/cli.py | 34 ++++++++-- tests/conftest.py | 11 ++- .../unit/application/test_create_playlist.py | 64 ++++++++++++++++++ tests/unit/domain/test_playlist.py | 19 ++++++ tests/unit/infrastructure/test_ytmusic.py | 58 +++++++++++++++- tests/unit/presentation/test_cli.py | 49 ++++++++++++++ 17 files changed, 432 insertions(+), 11 deletions(-) create mode 100644 docs/sqa/reviews/rc-015-mil-005-code.md create mode 100644 docs/wiki/images/step3-create-playlist.png create mode 100644 tests/unit/application/test_create_playlist.py create mode 100644 tests/unit/domain/test_playlist.py diff --git a/README.md b/README.md index 53f6387..dc14853 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ The program is added step by step (one pull request per lecture step): | --- | --- | | 1. Scrape the Billboard Hot 100 | done (below) | | 2. YouTube Music authentication | code done; live screenshot waits for `browser.json` | -| 3. Create the playlist | planned in milestone 5 | +| 3. Create the playlist | code done; live screenshot waits for `browser.json` | | 4. Search and add songs | planned in milestone 6 | ### Step 1 - Scrape the Billboard Hot 100 @@ -116,6 +116,18 @@ The screenshot below shows the run **without** a `browser.json` and the tests th ![Login check without browser.json and the step 2 tests](docs/wiki/images/step2-check-login.png) +### Step 3 - Create the playlist + +```bash +python -m musical_time_machine --create-playlist +``` + +It asks for the date and creates the private playlist `YYYY-MM-DD Billboard 100` in your YouTube Music library. It first looks through `get_library_playlists()` for a playlist with the same title, so a second run for the same date creates nothing and says so. + +The screenshot shows the tests of this step, which use a fake YouTube Music service (no account needed). A screenshot of the live playlist is added once `browser.json` exists (issue 27 stays open until then). + +![Tests of step 3: the playlist is created once](docs/wiki/images/step3-create-playlist.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 780941b..0884746 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 | 015 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 016 | ## Languages diff --git a/docs/sqa/reviews/rc-015-mil-005-code.md b/docs/sqa/reviews/rc-015-mil-005-code.md new file mode 100644 index 0000000..b959380 --- /dev/null +++ b/docs/sqa/reviews/rc-015-mil-005-code.md @@ -0,0 +1,61 @@ +# Review of the code of MIL-005 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-015 | +| CrossReference | [MIL-005], [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-005] (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 | Casing follows PEP 8. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (playlist_name, PlaylistCreationError). | +| 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 | All functions annotated including -> None. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | ytmusicapi errors are re-raised with `from`; a non-id answer raises PlaylistCreationError. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | Checked in the diff. | +| 7 | Files, locks and connections are managed with context managers | N-A | No files or connections are opened by the new code. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Checked in the diff. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | 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. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 65 tests, 98 % coverage, no network; first call, second call and other date are tested. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | N-A | mypy is not configured; the only dictionary-or-id return is typed in the YTMusicLike protocol. Optional criterion. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | Checked in the diff. | +| 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 | Title and description templates are in constants.py and built in one place each (domain/playlist.py). | +| 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 only constants; application imports domain; ytmusicapi only in infrastructure. | +| 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 | MusicService stays a narrow Protocol. | +| 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 | Layer folders and tests/unit/ 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 | English snake_case file names. | + +## Overall Verdict + +Go โ€” every Mandatory criterion passes or does not apply; no blocking finding. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| None | S01 | - | + +--- + +[MIL-005]: ../../milestones/mil-005-step-3-create-playlist.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md diff --git a/docs/sqa/traceability-matrix.md b/docs/sqa/traceability-matrix.md index 5132e6a..729f9a9 100644 --- a/docs/sqa/traceability-matrix.md +++ b/docs/sqa/traceability-matrix.md @@ -29,7 +29,7 @@ Tracks backward and forward links between artifact instances so that the traceab | [MIL-002] | MIL | en | it | [MIL-001] | [MIL-003] | [RC-006], [RC-012] | | [MIL-003] | MIL | en | it | [MIL-002] | [MIL-004] | [RC-007], [RC-013] | | [MIL-004] | MIL | en | it | [MIL-003] | [MIL-005] | [RC-008], [RC-014] | -| [MIL-005] | MIL | en | it | [MIL-004] | [MIL-006] | [RC-009] | +| [MIL-005] | MIL | en | it | [MIL-004] | [MIL-006] | [RC-009], [RC-015] | | [MIL-006] | MIL | en | it | [MIL-005] | [MIL-007] | [RC-010] | | [MIL-007] | MIL | en | it | [MIL-006] | - | [RC-011] | | [TM-001] | TM | - | - | [BC-001] | - | - | @@ -84,3 +84,4 @@ Tracks backward and forward links between artifact instances so that the traceab [RC-012]: ./reviews/rc-012-mil-002-code.md [RC-013]: ./reviews/rc-013-mil-003-code.md [RC-014]: ./reviews/rc-014-mil-004-code.md +[RC-015]: ./reviews/rc-015-mil-005-code.md diff --git a/docs/wiki/images/step3-create-playlist.png b/docs/wiki/images/step3-create-playlist.png new file mode 100644 index 0000000000000000000000000000000000000000..3cdd1765d96af8626256681b1c91d03b068c242b GIT binary patch literal 54370 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 str: + """@brief Create a playlist in the user's library. + + @param title Playlist title. + @param description Playlist description. + @param privacy Privacy status, for example "PRIVATE". + @return The identifier of the new playlist. + """ + ... diff --git a/src/musical_time_machine/application/use_cases.py b/src/musical_time_machine/application/use_cases.py index 9f2e674..dd945d9 100644 --- a/src/musical_time_machine/application/use_cases.py +++ b/src/musical_time_machine/application/use_cases.py @@ -2,12 +2,29 @@ @brief Use cases of the Musical Time Machine. """ +from dataclasses import dataclass from datetime import date from musical_time_machine.application.ports import ChartSource, MusicService +from musical_time_machine.constants import PLAYLIST_PRIVACY +from musical_time_machine.domain.playlist import playlist_description, playlist_name from musical_time_machine.domain.song import Song +@dataclass(frozen=True, slots=True) +class PlaylistResult: + """@brief Outcome of create_playlist. + + @param playlist_id Identifier of the new or the existing playlist. + @param title Playlist title. + @param created True when a playlist was made, False when it already existed. + """ + + playlist_id: str + title: str + created: bool + + def scrape_songs(chart_date: date, source: ChartSource) -> list[Song]: """@brief Get the songs of the chart of a date. @@ -35,3 +52,18 @@ def count_library_playlists(service: MusicService) -> int: @return The number of playlists. """ return len(service.library_playlists()) + + +def create_playlist(chart_date: date, service: MusicService) -> PlaylistResult: + """@brief Create the private playlist of a chart date unless it already exists. + + @param chart_date Date of the chart. + @param service The music service to ask. + @return The new playlist, or the existing one with the same title. + """ + title = playlist_name(chart_date) + for playlist in service.library_playlists(): + if playlist.title == title: + return PlaylistResult(playlist.playlist_id, title, created=False) + playlist_id = service.create_playlist(title, playlist_description(chart_date), PLAYLIST_PRIVACY) + return PlaylistResult(playlist_id, title, created=True) diff --git a/src/musical_time_machine/constants.py b/src/musical_time_machine/constants.py index 7cb1b0a..5e6408d 100644 --- a/src/musical_time_machine/constants.py +++ b/src/musical_time_machine/constants.py @@ -126,3 +126,24 @@ BROWSER_FILE_FLAG: Final[str] = "--browser-file" ## @brief Help text of the browser-file flag. BROWSER_FILE_HELP: Final[str] = "Path of the ytmusicapi browser headers file." ## @} + +## @name Create playlist +## @{ + +## @brief Command line flag that creates the dated playlist. +CREATE_PLAYLIST_FLAG: Final[str] = "--create-playlist" + +## @brief Help text of the create-playlist flag. +CREATE_PLAYLIST_HELP: Final[str] = ( + "Create the private playlist ' Billboard 100' in YouTube Music." +) + +## @brief Message after a playlist was created; format with the title. +PLAYLIST_CREATED_MESSAGE: Final[str] = 'Created playlist "{title}".' + +## @brief Message when the playlist already exists; format with the title. +PLAYLIST_EXISTS_MESSAGE: Final[str] = 'Playlist "{title}" already exists; nothing was created.' + +## @brief Message when YouTube Music returned no playlist id; format with the answer. +PLAYLIST_CREATION_FAILED_MESSAGE: Final[str] = "The playlist could not be created: {reason}" +## @} diff --git a/src/musical_time_machine/domain/errors.py b/src/musical_time_machine/domain/errors.py index 0ebf324..383844a 100644 --- a/src/musical_time_machine/domain/errors.py +++ b/src/musical_time_machine/domain/errors.py @@ -25,3 +25,7 @@ class BrowserFileMissingError(MusicalTimeMachineError): class AuthenticationError(MusicalTimeMachineError): """@brief YouTube Music rejected the browser authentication headers.""" + + +class PlaylistCreationError(MusicalTimeMachineError): + """@brief YouTube Music did not create the playlist.""" diff --git a/src/musical_time_machine/domain/playlist.py b/src/musical_time_machine/domain/playlist.py index c2c5f58..0ee509e 100644 --- a/src/musical_time_machine/domain/playlist.py +++ b/src/musical_time_machine/domain/playlist.py @@ -3,6 +3,13 @@ """ from dataclasses import dataclass +from datetime import date + +from musical_time_machine.constants import ( + DATE_FORMAT, + PLAYLIST_DESCRIPTION_TEMPLATE, + PLAYLIST_NAME_TEMPLATE, +) @dataclass(frozen=True, slots=True) @@ -15,3 +22,21 @@ class Playlist: playlist_id: str title: str + + +def playlist_name(chart_date: date) -> str: + """@brief Get the title of the playlist for a chart date. + + @param chart_date Date of the chart. + @return The title, for example "2026-04-18 Billboard 100". + """ + return PLAYLIST_NAME_TEMPLATE.format(date=chart_date.strftime(DATE_FORMAT)) + + +def playlist_description(chart_date: date) -> str: + """@brief Get the description of the playlist for a chart date. + + @param chart_date Date of the chart. + @return The description text. + """ + return PLAYLIST_DESCRIPTION_TEMPLATE.format(date=chart_date.strftime(DATE_FORMAT)) diff --git a/src/musical_time_machine/infrastructure/ytmusic.py b/src/musical_time_machine/infrastructure/ytmusic.py index 074bc74..62c86b2 100644 --- a/src/musical_time_machine/infrastructure/ytmusic.py +++ b/src/musical_time_machine/infrastructure/ytmusic.py @@ -14,8 +14,13 @@ from musical_time_machine.constants import ( BROWSER_FILE_MISSING_REASON, BROWSER_FILE_NAME, BROWSER_RENEWAL_MESSAGE, + PLAYLIST_CREATION_FAILED_MESSAGE, +) +from musical_time_machine.domain.errors import ( + AuthenticationError, + BrowserFileMissingError, + PlaylistCreationError, ) -from musical_time_machine.domain.errors import AuthenticationError, BrowserFileMissingError from musical_time_machine.domain.playlist import Playlist _logger = logging.getLogger(__name__) @@ -34,6 +39,15 @@ class YTMusicLike(Protocol): """@brief List the library playlists as raw dictionaries.""" ... + def create_playlist( + self, + title: str, + description: str, + privacy_status: str = ..., + ) -> str | dict[str, Any]: # the id on success, an error dictionary on failure + """@brief Create a playlist; returns its id or an error dictionary.""" + ... + class YouTubeMusicService: """@brief MusicService backed by ytmusicapi and a browser headers file.""" @@ -70,6 +84,26 @@ class YouTubeMusicService: except _CLIENT_ERRORS as error: raise self._rejected(error) from error + def create_playlist(self, title: str, description: str, privacy: str) -> str: + """@brief Create a playlist in the user's library. + + @param title Playlist title. + @param description Playlist description. + @param privacy Privacy status, for example "PRIVATE". + @return The identifier of the new playlist. + @throws BrowserFileMissingError when the browser file does not exist. + @throws AuthenticationError when YouTube Music rejects the headers. + @throws PlaylistCreationError when YouTube Music answers without a playlist id. + """ + client = self._connect() + try: + result = client.create_playlist(title, description, privacy_status=privacy) + except _CLIENT_ERRORS as error: + raise self._rejected(error) from error + if not isinstance(result, str): + raise PlaylistCreationError(PLAYLIST_CREATION_FAILED_MESSAGE.format(reason=result)) + return result + def _connect(self) -> YTMusicLike: if self._client is not None: return self._client diff --git a/src/musical_time_machine/presentation/cli.py b/src/musical_time_machine/presentation/cli.py index f8c60d4..6dba04d 100644 --- a/src/musical_time_machine/presentation/cli.py +++ b/src/musical_time_machine/presentation/cli.py @@ -7,17 +7,26 @@ from collections.abc import Callable from pathlib import Path from musical_time_machine.application.ports import ChartSource, MusicService -from musical_time_machine.application.use_cases import count_library_playlists, scrape_songs +from musical_time_machine.application.use_cases import ( + PlaylistResult, + count_library_playlists, + create_playlist, + scrape_songs, +) from musical_time_machine.constants import ( BROWSER_FILE_FLAG, BROWSER_FILE_HELP, BROWSER_FILE_NAME, CHECK_LOGIN_FLAG, CHECK_LOGIN_HELP, + CREATE_PLAYLIST_FLAG, + CREATE_PLAYLIST_HELP, DATE_PROMPT, EXIT_FAILURE, EXIT_OK, PLAYLIST_COUNT_MESSAGE, + PLAYLIST_CREATED_MESSAGE, + PLAYLIST_EXISTS_MESSAGE, SONG_LINE_TEMPLATE, ) from musical_time_machine.domain.chart_date import parse_chart_date @@ -29,6 +38,7 @@ from musical_time_machine.infrastructure.ytmusic import YouTubeMusicService def _parse_args(argv: list[str] | None) -> argparse.Namespace: parser = argparse.ArgumentParser(prog="musical_time_machine") parser.add_argument(CHECK_LOGIN_FLAG, action="store_true", help=CHECK_LOGIN_HELP) + parser.add_argument(CREATE_PLAYLIST_FLAG, action="store_true", help=CREATE_PLAYLIST_HELP) parser.add_argument( BROWSER_FILE_FLAG, type=Path, default=Path(BROWSER_FILE_NAME), help=BROWSER_FILE_HELP ) @@ -45,6 +55,19 @@ def _check_login(service: MusicService, write: Callable[[str], None]) -> int: return EXIT_OK +def _create_playlist( + service: MusicService, read: Callable[[str], str], write: Callable[[str], None] +) -> int: + try: + result: PlaylistResult = create_playlist(parse_chart_date(read(DATE_PROMPT)), service) + except MusicalTimeMachineError as error: + write(str(error)) + return EXIT_FAILURE + message = PLAYLIST_CREATED_MESSAGE if result.created else PLAYLIST_EXISTS_MESSAGE + write(message.format(title=result.title)) + return EXIT_OK + + def main( argv: list[str] | None = None, *, @@ -53,11 +76,11 @@ def main( read: Callable[[str], str] = input, write: Callable[[str], None] = print, ) -> int: - """@brief Ask for a date and print the songs of that chart, or check the YouTube Music login. + """@brief Ask for a date and print that chart, check the login or create a playlist. - @param argv Command line arguments: `--check-login` and `--browser-file PATH`. + @param argv Arguments: `--check-login`, `--create-playlist`, `--browser-file PATH`. @param source Chart source; the Bakeboard site when omitted. - @param music_service Music service for `--check-login`; YouTube Music when omitted. + @param music_service Music service for the YouTube Music flags; YouTube Music 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. @@ -66,6 +89,9 @@ def main( if args.check_login: service = music_service or YouTubeMusicService(browser_file=args.browser_file) return _check_login(service, write) + if args.create_playlist: + service = music_service or YouTubeMusicService(browser_file=args.browser_file) + return _create_playlist(service, read, write) chart_source = source if source is not None else BakeboardChartSource() try: chart_date = parse_chart_date(read(DATE_PROMPT)) diff --git a/tests/conftest.py b/tests/conftest.py index 3e48fdd..e5231a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,8 +40,17 @@ class FakeMusicService: def __init__( self, playlists: list[Playlist] | None = None, error: Exception | None = None ) -> None: - self._playlists = playlists or [] + self._playlists = list(playlists or []) self._error = error + self.created: list[tuple[str, str, str]] = [] + + def create_playlist(self, title: str, description: str, privacy: str) -> str: + if self._error is not None: + raise self._error + self.created.append((title, description, privacy)) + playlist_id = f"new-{len(self._playlists) + 1}" + self._playlists.append(Playlist(playlist_id, title)) + return playlist_id def library_playlists(self) -> list[Playlist]: if self._error is not None: diff --git a/tests/unit/application/test_create_playlist.py b/tests/unit/application/test_create_playlist.py new file mode 100644 index 0000000..77272b4 --- /dev/null +++ b/tests/unit/application/test_create_playlist.py @@ -0,0 +1,64 @@ +"""@file +@brief Tests of the create_playlist use case. +""" + +from datetime import date + +import pytest +from conftest import FakeMusicService + +from musical_time_machine.application.use_cases import create_playlist +from musical_time_machine.domain.errors import AuthenticationError +from musical_time_machine.domain.playlist import Playlist + +TITLE = "2026-04-18 Billboard 100" + + +def test_create_playlist_creates_one_private_playlist() -> None: + service = FakeMusicService() + + result = create_playlist(date(2026, 4, 18), service) + + assert result.created is True + assert result.title == TITLE + assert len(service.created) == 1 + title, description, privacy = service.created[0] + assert (title, privacy) == (TITLE, "PRIVATE") + assert "2026-04-18" in description + assert result.playlist_id == service.library_playlists()[0].playlist_id + + +def test_create_playlist_twice_for_same_date_creates_no_duplicate() -> None: + service = FakeMusicService() + first = create_playlist(date(2026, 4, 18), service) + + second = create_playlist(date(2026, 4, 18), service) + + assert second.created is False + assert second.playlist_id == first.playlist_id + assert len(service.created) == 1 + assert len(service.library_playlists()) == 1 + + +def test_create_playlist_for_other_date_creates_second_playlist() -> None: + service = FakeMusicService() + create_playlist(date(2026, 4, 18), service) + + result = create_playlist(date(2026, 4, 19), service) + + assert result.created is True + assert len(service.library_playlists()) == 2 + + +def test_create_playlist_returns_existing_playlist_of_library() -> None: + service = FakeMusicService([Playlist("x", "Other"), Playlist("abc", TITLE)]) + + result = create_playlist(date(2026, 4, 18), service) + + assert (result.playlist_id, result.created) == ("abc", False) + assert service.created == [] + + +def test_create_playlist_propagates_authentication_error() -> None: + with pytest.raises(AuthenticationError): + create_playlist(date(2026, 4, 18), FakeMusicService(error=AuthenticationError("no"))) diff --git a/tests/unit/domain/test_playlist.py b/tests/unit/domain/test_playlist.py new file mode 100644 index 0000000..1ebd650 --- /dev/null +++ b/tests/unit/domain/test_playlist.py @@ -0,0 +1,19 @@ +"""@file +@brief Tests of the playlist naming rules. +""" + +from datetime import date + +from musical_time_machine.domain.playlist import playlist_description, playlist_name + + +def test_playlist_name_uses_iso_date_and_suffix() -> None: + assert playlist_name(date(2026, 4, 18)) == "2026-04-18 Billboard 100" + + +def test_playlist_name_zero_pads_month_and_day() -> None: + assert playlist_name(date(2026, 1, 5)) == "2026-01-05 Billboard 100" + + +def test_playlist_description_names_the_chart_date() -> None: + assert "2026-04-18" in playlist_description(date(2026, 4, 18)) diff --git a/tests/unit/infrastructure/test_ytmusic.py b/tests/unit/infrastructure/test_ytmusic.py index 78b4d51..2cf7f5b 100644 --- a/tests/unit/infrastructure/test_ytmusic.py +++ b/tests/unit/infrastructure/test_ytmusic.py @@ -8,16 +8,35 @@ from typing import Any import pytest from ytmusicapi.exceptions import YTMusicUserError -from musical_time_machine.domain.errors import AuthenticationError, BrowserFileMissingError +from musical_time_machine.domain.errors import ( + AuthenticationError, + BrowserFileMissingError, + PlaylistCreationError, +) from musical_time_machine.domain.playlist import Playlist from musical_time_machine.infrastructure.ytmusic import YouTubeMusicService, YTMusicLike class _FakeClient: - def __init__(self, items: list[dict[str, Any]], error: Exception | None = None) -> None: + def __init__( + self, + items: list[dict[str, Any]], + error: Exception | None = None, + created: str | dict[str, Any] = "new-id", + ) -> None: self._items = items self._error = error + self._created = created self.limits: list[int | None] = [] + self.creations: list[tuple[str, str, str]] = [] + + def create_playlist( + self, title: str, description: str, privacy_status: str = "PRIVATE" + ) -> str | dict[str, Any]: + self.creations.append((title, description, privacy_status)) + if self._error is not None: + raise self._error + return self._created def get_library_playlists(self, limit: int | None = 25) -> list[dict[str, Any]]: self.limits.append(limit) @@ -108,3 +127,38 @@ def test_library_playlists_builds_the_client_once(tmp_path: Path) -> None: service.library_playlists() assert len(calls) == 1 + + +def test_create_playlist_returns_id_and_passes_privacy(tmp_path: Path) -> None: + client = _FakeClient([]) + service = YouTubeMusicService(_browser_file(tmp_path), lambda _auth: client) + + assert service.create_playlist("T", "D", "PRIVATE") == "new-id" + assert client.creations == [("T", "D", "PRIVATE")] + + +def test_create_playlist_raises_when_result_is_not_an_id(tmp_path: Path) -> None: + client = _FakeClient([], created={"error": "quota exceeded"}) + service = YouTubeMusicService(_browser_file(tmp_path), lambda _auth: client) + + with pytest.raises(PlaylistCreationError, match="quota exceeded"): + service.create_playlist("T", "D", "PRIVATE") + + +@pytest.mark.parametrize("error", [YTMusicUserError("x"), KeyError("cookie"), ValueError("bad")]) +def test_create_playlist_raises_authentication_error_when_client_fails( + tmp_path: Path, error: Exception +) -> None: + client = _FakeClient([], error) + service = YouTubeMusicService(_browser_file(tmp_path), lambda _auth: client) + + with pytest.raises(AuthenticationError) as info: + service.create_playlist("T", "D", "PRIVATE") + assert info.value.__cause__ is error + + +def test_create_playlist_raises_when_browser_file_missing(tmp_path: Path) -> None: + service = YouTubeMusicService(tmp_path / "none.json", lambda _auth: _FakeClient([])) + + with pytest.raises(BrowserFileMissingError): + service.create_playlist("T", "D", "PRIVATE") diff --git a/tests/unit/presentation/test_cli.py b/tests/unit/presentation/test_cli.py index d512c1d..3d92b0c 100644 --- a/tests/unit/presentation/test_cli.py +++ b/tests/unit/presentation/test_cli.py @@ -11,6 +11,7 @@ from musical_time_machine.domain.errors import ( BrowserFileMissingError, ChartParseError, ChartUnavailableError, + PlaylistCreationError, ) from musical_time_machine.domain.playlist import Playlist from musical_time_machine.domain.song import Song @@ -110,3 +111,51 @@ def test_main_without_flag_does_not_use_the_music_service() -> None: assert code == 0 assert lines == ["1. T - A"] + + +def _create(answer: str, service: FakeMusicService) -> tuple[int, list[str]]: + lines: list[str] = [] + code = main( + ["--create-playlist"], + music_service=service, + read=lambda _prompt: answer, + write=lines.append, + ) + return code, lines + + +def test_main_create_playlist_reports_creation() -> None: + service = FakeMusicService() + + code, lines = _create("2026-04-18", service) + + assert code == 0 + assert lines == ['Created playlist "2026-04-18 Billboard 100".'] + assert len(service.created) == 1 + + +def test_main_create_playlist_reports_existing_playlist() -> None: + service = FakeMusicService([Playlist("a", "2026-04-18 Billboard 100")]) + + code, lines = _create("2026-04-18", service) + + assert code == 0 + assert lines == ['Playlist "2026-04-18 Billboard 100" already exists; nothing was created.'] + assert service.created == [] + + +def test_main_create_playlist_returns_one_for_invalid_date() -> None: + service = FakeMusicService() + + code, lines = _create("nope", service) + + assert code == 1 + assert "YYYY-MM-DD" in lines[0] + assert service.created == [] + + +def test_main_create_playlist_returns_one_on_domain_error() -> None: + code, lines = _create("2026-04-18", FakeMusicService(error=PlaylistCreationError("failed"))) + + assert code == 1 + assert lines == ["failed"] -- 2.54.0 From f40eaf508235eea64966c53044d8b6479d4f203d Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:32:37 +0800 Subject: [PATCH 2/2] Resolve pending commit links of the step 3 review Task: MIL-005#1 --- docs/sqa/reviews/rc-015-mil-005-code.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-015-mil-005-code.md b/docs/sqa/reviews/rc-015-mil-005-code.md index b959380..215a664 100644 --- a/docs/sqa/reviews/rc-015-mil-005-code.md +++ b/docs/sqa/reviews/rc-015-mil-005-code.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | pending | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [27a0606] | --- @@ -59,3 +59,4 @@ Go โ€” every Mandatory criterion passes or does not apply; no blocking finding. [MIL-005]: ../../milestones/mil-005-step-3-create-playlist.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[27a0606]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/046-scraping-the-billboard/commit/27a060650a64184c9393f0cb9b9edd68fa8340c8 -- 2.54.0