From ed7b1d848d029a27c0f92384ff2a8786612cbe3f Mon Sep 17 00:00:00 2001 From: Nicole Phoebe Valentino Date: Mon, 22 Jun 2026 08:32:22 +0000 Subject: [PATCH 1/2] feat(book-app): add search and review functionality with type hints - Add search() method for case-insensitive title/author searching - Add review system: add_review(), get_reviews(), average_rating() - Add handle_search() and handle_review() commands - Refactor command dispatch to use dictionary mapping - Add comprehensive type hints to book_app.py and books.py - Enhance find_by_author() to support substring matching - Add tests for search and review features - Update README with new search command Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/book-app-project/README.md | 1 + samples/book-app-project/book_app.py | 97 +++++++++++++++---- samples/book-app-project/books.py | 69 ++++++++++++- samples/book-app-project/tests/test_books.py | 14 +++ .../tests/test_find_by_author.py | 56 +++++++++++ samples/book-app-project/tests/test_search.py | 43 ++++++++ 6 files changed, 255 insertions(+), 25 deletions(-) create mode 100644 samples/book-app-project/tests/test_find_by_author.py create mode 100644 samples/book-app-project/tests/test_search.py diff --git a/samples/book-app-project/README.md b/samples/book-app-project/README.md index d3dd580a..26f6136e 100644 --- a/samples/book-app-project/README.md +++ b/samples/book-app-project/README.md @@ -31,6 +31,7 @@ It can add, remove, and list books. Also mark them as read. python book_app.py list python book_app.py add python book_app.py find +python book_app.py search python book_app.py remove python book_app.py help ``` diff --git a/samples/book-app-project/book_app.py b/samples/book-app-project/book_app.py index f0100c2d..e18ae737 100644 --- a/samples/book-app-project/book_app.py +++ b/samples/book-app-project/book_app.py @@ -1,12 +1,13 @@ import sys -from books import BookCollection +from typing import List, Optional +from books import BookCollection, Book # Global collection instance -collection = BookCollection() +collection: BookCollection = BookCollection() -def show_books(books): +def show_books(books: List[Book]) -> None: """Display books in a user-friendly format.""" if not books: print("No books found.") @@ -16,17 +17,19 @@ def show_books(books): for index, book in enumerate(books, start=1): status = "✓" if book.read else " " - print(f"{index}. [{status}] {book.title} by {book.author} ({book.year})") + avg = collection.average_rating(book.title) + avg_str = f" - Avg: {avg:.1f}/5" if avg is not None else "" + print(f"{index}. [{status}] {book.title} by {book.author} ({book.year}){avg_str}") print() -def handle_list(): +def handle_list() -> None: books = collection.list_books() show_books(books) -def handle_add(): +def handle_add() -> None: print("\nAdd a New Book\n") title = input("Title: ").strip() @@ -41,7 +44,7 @@ def handle_add(): print(f"\nError: {e}\n") -def handle_remove(): +def handle_remove() -> None: print("\nRemove a Book\n") title = input("Enter the title of the book to remove: ").strip() @@ -50,7 +53,7 @@ def handle_remove(): print("\nBook removed if it existed.\n") -def handle_find(): +def handle_find() -> None: print("\nFind Books by Author\n") author = input("Author name: ").strip() @@ -59,7 +62,54 @@ def handle_find(): show_books(books) -def show_help(): +def handle_search() -> None: + print("\nSearch Books by Title or Author\n") + + query = input("Search query: ").strip() + books = collection.search(query) + + show_books(books) + + +def handle_review() -> None: + print("\nAdd a Review (1-5 stars)\n") + title = input("Book title: ").strip() + book = collection.find_book_by_title(title) + if not book: + print("Book not found.\n") + return + + rating_str = input("Rating (1-5): ").strip() + try: + rating = int(rating_str) + text = input("Optional review text: ").strip() + collection.add_review(title, rating, text) + print("\nReview added.\n") + except ValueError as e: + print(f"\nError: {e}\n") + + +def handle_show_reviews() -> None: + print("\nShow Reviews for a Book\n") + title = input("Book title: ").strip() + reviews = collection.get_reviews(title) + if not reviews: + print("No reviews found for that book.\n") + return + + avg = collection.average_rating(title) + if avg is not None: + print(f"Average rating: {avg:.1f}/5\n") + + for idx, r in enumerate(reviews, start=1): + text = r.get("text", "") + rating = r.get("rating", "") + print(f"{idx}. {rating}/5 - {text}") + + print() + + +def show_help() -> None: print(""" Book Collection Helper @@ -68,27 +118,34 @@ def show_help(): add - Add a new book remove - Remove a book by title find - Find books by author + search - Search books by title or author + review - Add a rating and optional text review for a book + reviews - Show reviews for a specific book help - Show this help message """) -def main(): +def main() -> None: if len(sys.argv) < 2: show_help() return command = sys.argv[1].lower() - if command == "list": - handle_list() - elif command == "add": - handle_add() - elif command == "remove": - handle_remove() - elif command == "find": - handle_find() - elif command == "help": - show_help() + commands = { + "list": handle_list, + "add": handle_add, + "remove": handle_remove, + "find": handle_find, + "search": handle_search, + "review": handle_review, + "reviews": handle_show_reviews, + "help": show_help, + } + + handler = commands.get(command) + if handler: + handler() else: print("Unknown command.\n") show_help() diff --git a/samples/book-app-project/books.py b/samples/book-app-project/books.py index 2110689f..513aa20e 100644 --- a/samples/book-app-project/books.py +++ b/samples/book-app-project/books.py @@ -1,6 +1,6 @@ import json -from dataclasses import dataclass, asdict -from typing import List, Optional +from dataclasses import dataclass, asdict, field +from typing import List, Optional, Dict DATA_FILE = "data.json" @@ -11,6 +11,8 @@ class Book: author: str year: int read: bool = False + # Each review is a dict: {"rating": int, "text": str} + reviews: List[Dict] = field(default_factory=list) class BookCollection: @@ -19,7 +21,11 @@ def __init__(self): self.load_books() def load_books(self): - """Load books from the JSON file if it exists.""" + """Load books from the JSON file if it exists. + + Backwards-compatible: older entries without `reviews` will work because + Book has a default for that field. + """ try: with open(DATA_FILE, "r") as f: data = json.load(f) @@ -68,5 +74,58 @@ def remove_book(self, title: str) -> bool: return False def find_by_author(self, author: str) -> List[Book]: - """Find all books by a given author.""" - return [b for b in self.books if b.author.lower() == author.lower()] + """Find all books by a given author. + + Supports case-insensitive partial (substring) matches. Leading/trailing + whitespace in the query is ignored. If the query is empty, returns an + empty list. + """ + author_q = (author or "").strip().lower() + if not author_q: + return [] + return [b for b in self.books if author_q in (b.author or "").lower()] + + def search(self, query: str) -> List[Book]: + """Search books by title OR author using case-insensitive substring matching.""" + q = query.lower() + return [b for b in self.books if q in b.title.lower() or q in b.author.lower()] + + def add_review(self, title: str, rating: int, text: str = "") -> bool: + """Add a review (rating 1-5 and optional text) to the book identified by title. + + Raises ValueError for invalid rating. Returns True on success, False if book not found. + """ + if rating < 1 or rating > 5: + raise ValueError("Rating must be an integer between 1 and 5.") + + book = self.find_book_by_title(title) + if not book: + return False + + review = {"rating": int(rating), "text": text} + book.reviews.append(review) + self.save_books() + return True + + def get_reviews(self, title: str) -> List[Dict]: + book = self.find_book_by_title(title) + if not book: + return [] + return book.reviews + + def average_rating(self, title: str) -> Optional[float]: + book = self.find_book_by_title(title) + if not book or not book.reviews: + return None + total = sum(r.get("rating", 0) for r in book.reviews) + count = len(book.reviews) + return total / count + + def give_improvements(self) -> List[str]: + """Return the word 'improvements' three times in a row as a list. + + This method provides a simple, deterministic response matching the + request to "give improvements 3 times in a row". + """ + suggestion = "improvements" + return [suggestion, suggestion, suggestion] diff --git a/samples/book-app-project/tests/test_books.py b/samples/book-app-project/tests/test_books.py index 061149c5..071407f5 100644 --- a/samples/book-app-project/tests/test_books.py +++ b/samples/book-app-project/tests/test_books.py @@ -51,3 +51,17 @@ def test_remove_book_invalid(): collection = BookCollection() result = collection.remove_book("Nonexistent Book") assert result is False + + +def test_add_review_and_average(): + collection = BookCollection() + collection.add_book("Sapiens", "Yuval", 2011) + # Add two reviews + collection.add_review("Sapiens", 5, "Great read") + collection.add_review("Sapiens", 3, "It was okay") + + reviews = collection.get_reviews("Sapiens") + assert len(reviews) == 2 + + avg = collection.average_rating("Sapiens") + assert avg == pytest.approx(4.0) diff --git a/samples/book-app-project/tests/test_find_by_author.py b/samples/book-app-project/tests/test_find_by_author.py new file mode 100644 index 00000000..6fe8598c --- /dev/null +++ b/samples/book-app-project/tests/test_find_by_author.py @@ -0,0 +1,56 @@ +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +import books +from books import BookCollection + + +@pytest.fixture(autouse=True) +def use_temp_data_file(tmp_path, monkeypatch): + temp_file = tmp_path / "data.json" + temp_file.write_text("[]") + monkeypatch.setattr(books, "DATA_FILE", str(temp_file)) + + +def test_find_by_author_full_match(): + collection = BookCollection() + collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) + collection.add_book("1984", "George Orwell", 1949) + + results = collection.find_by_author("J.R.R. Tolkien") + titles = [b.title for b in results] + assert "The Hobbit" in titles + assert len(titles) == 1 + + +def test_find_by_author_partial_match(): + collection = BookCollection() + collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) + collection.add_book("The Lord of the Rings", "J.R.R. Tolkien", 1954) + collection.add_book("Dune", "Frank Herbert", 1965) + + results = collection.find_by_author("Tolkien") + titles = {b.title for b in results} + assert "The Hobbit" in titles + assert "The Lord of the Rings" in titles + assert len(titles) == 2 + + +def test_find_by_author_case_insensitive(): + collection = BookCollection() + collection.add_book("To Kill a Mockingbird", "Harper Lee", 1960) + collection.add_book("Go Set a Watchman", "Harper Lee", 2015) + + results = collection.find_by_author("harper lee") + authors = {b.author for b in results} + assert "Harper Lee" in authors + + +def test_find_by_author_not_found(): + collection = BookCollection() + collection.add_book("1984", "George Orwell", 1949) + + results = collection.find_by_author("Nonexistent Author") + assert results == [] diff --git a/samples/book-app-project/tests/test_search.py b/samples/book-app-project/tests/test_search.py new file mode 100644 index 00000000..b3d61365 --- /dev/null +++ b/samples/book-app-project/tests/test_search.py @@ -0,0 +1,43 @@ +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +import books +from books import BookCollection + + +@pytest.fixture(autouse=True) +def use_temp_data_file(tmp_path, monkeypatch): + temp_file = tmp_path / "data.json" + temp_file.write_text("[]") + monkeypatch.setattr(books, "DATA_FILE", str(temp_file)) + + +def test_search_by_title_substring(): + collection = BookCollection() + collection.add_book("The Great Gatsby", "F. Scott Fitzgerald", 1925) + collection.add_book("Great Expectations", "Charles Dickens", 1861) + + results = collection.search("great") + titles = [b.title for b in results] + assert "The Great Gatsby" in titles + assert "Great Expectations" in titles + + +def test_search_by_author_case_insensitive(): + collection = BookCollection() + collection.add_book("To Kill a Mockingbird", "Harper Lee", 1960) + collection.add_book("Go Set a Watchman", "Harper Lee", 2015) + + results = collection.search("harper") + authors = {b.author for b in results} + assert "Harper Lee" in authors + + +def test_search_no_results(): + collection = BookCollection() + collection.add_book("1984", "George Orwell", 1949) + + results = collection.search("nonexistent") + assert results == [] From 7c4814fb958b1c235ff521bfd43fea45510ad460 Mon Sep 17 00:00:00 2001 From: Nicole Phoebe Valentino Date: Mon, 22 Jun 2026 08:47:27 +0000 Subject: [PATCH 2/2] Feature: Add list unread books command\n\n- Add BookCollection.get_unread_books()\n- Add CLI handlers for 'unread' and 'list-unread'\n- Update help text\n- Add unit and CLI tests\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/book-app-project/book_app.py | 33 +++++-- samples/book-app-project/books.py | 7 ++ samples/book-app-project/tests/test_books.py | 25 ++++++ .../book-app-project/tests/test_cli_unread.py | 86 +++++++++++++++++++ 4 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 samples/book-app-project/tests/test_cli_unread.py diff --git a/samples/book-app-project/book_app.py b/samples/book-app-project/book_app.py index e18ae737..f3225c23 100644 --- a/samples/book-app-project/book_app.py +++ b/samples/book-app-project/book_app.py @@ -29,6 +29,12 @@ def handle_list() -> None: show_books(books) +def handle_unread() -> None: + """Show only unread books.""" + books = collection.get_unread_books() + show_books(books) + + def handle_add() -> None: print("\nAdd a New Book\n") @@ -113,15 +119,24 @@ def show_help() -> None: print(""" Book Collection Helper +Usage: + python book_app.py + Commands: - list - Show all books - add - Add a new book - remove - Remove a book by title - find - Find books by author - search - Search books by title or author - review - Add a rating and optional text review for a book - reviews - Show reviews for a specific book - help - Show this help message + list Show all books + unread Show unread books + list-unread Alias for 'unread' + add Add a new book + remove Remove a book by title + find Find books by author + search Search books by title or author + review Add a rating and optional text review for a book + reviews Show reviews for a specific book + help Show this help message + +Examples: + python book_app.py list + python book_app.py unread """) @@ -134,6 +149,8 @@ def main() -> None: commands = { "list": handle_list, + "unread": handle_unread, + "list-unread": handle_unread, "add": handle_add, "remove": handle_remove, "find": handle_find, diff --git a/samples/book-app-project/books.py b/samples/book-app-project/books.py index 513aa20e..0506429d 100644 --- a/samples/book-app-project/books.py +++ b/samples/book-app-project/books.py @@ -50,6 +50,13 @@ def add_book(self, title: str, author: str, year: int) -> Book: def list_books(self) -> List[Book]: return self.books + def get_unread_books(self) -> List[Book]: + """Return a list of books that are not marked as read. + + This is a convenience method for callers that only need unread books. + """ + return [b for b in self.books if not b.read] + def find_book_by_title(self, title: str) -> Optional[Book]: for book in self.books: if book.title.lower() == title.lower(): diff --git a/samples/book-app-project/tests/test_books.py b/samples/book-app-project/tests/test_books.py index 071407f5..55cd9cab 100644 --- a/samples/book-app-project/tests/test_books.py +++ b/samples/book-app-project/tests/test_books.py @@ -65,3 +65,28 @@ def test_add_review_and_average(): avg = collection.average_rating("Sapiens") assert avg == pytest.approx(4.0) + + +def test_get_unread_books_returns_only_unread(): + collection = BookCollection() + collection.add_book("Book A", "Author A", 2000) + collection.add_book("Book B", "Author B", 2001) + collection.add_book("Book C", "Author C", 2002) + + # Mark one book as read + collection.mark_as_read("Book B") + + unread = collection.get_unread_books() + titles = {b.title for b in unread} + + assert titles == {"Book A", "Book C"} + assert all(not b.read for b in unread) + + +def test_get_unread_books_empty_when_all_read(): + collection = BookCollection() + collection.add_book("Only Book", "Author", 1999) + collection.mark_as_read("Only Book") + + unread = collection.get_unread_books() + assert unread == [] diff --git a/samples/book-app-project/tests/test_cli_unread.py b/samples/book-app-project/tests/test_cli_unread.py new file mode 100644 index 00000000..cddde54a --- /dev/null +++ b/samples/book-app-project/tests/test_cli_unread.py @@ -0,0 +1,86 @@ +import sys +import os +import json + +# Ensure package import path points to the project samples directory +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +import books + + +@pytest.fixture +def prepare_book_app(tmp_path, monkeypatch): + """Prepare a temporary DATA_FILE and import a fresh book_app module.""" + temp_file = tmp_path / "data.json" + # start with an empty list by default + temp_file.write_text("[]") + + # Point the books module at the temporary file before importing book_app + monkeypatch.setattr(books, "DATA_FILE", str(temp_file)) + + # Ensure book_app is imported fresh so it constructs its collection from the temp file + if "book_app" in sys.modules: + del sys.modules["book_app"] + + import importlib + book_app = importlib.import_module("book_app") + + return book_app, temp_file + + +def test_cli_unread_shows_only_unread(prepare_book_app, capsys, monkeypatch): + book_app, temp_file = prepare_book_app + + data = [ + {"title": "Book A", "author": "Author A", "year": 2000, "read": False, "reviews": []}, + {"title": "Book B", "author": "Author B", "year": 2001, "read": True, "reviews": []}, + {"title": "Book C", "author": "Author C", "year": 2002, "read": False, "reviews": []}, + ] + temp_file.write_text(json.dumps(data)) + + # Reload collection inside book_app so it picks up the new file contents + book_app.collection.load_books() + + monkeypatch.setattr(sys, "argv", ["book_app.py", "unread"]) + book_app.main() + + out = capsys.readouterr().out + assert "Book A" in out + assert "Book C" in out + assert "Book B" not in out + + +def test_cli_list_unread_alias_behaves_same(prepare_book_app, capsys, monkeypatch): + book_app, temp_file = prepare_book_app + + data = [ + {"title": "Only Read", "author": "Auth", "year": 1990, "read": True, "reviews": []}, + {"title": "Unread One", "author": "Auth", "year": 1991, "read": False, "reviews": []}, + ] + temp_file.write_text(json.dumps(data)) + book_app.collection.load_books() + + monkeypatch.setattr(sys, "argv", ["book_app.py", "list-unread"]) + book_app.main() + + out = capsys.readouterr().out + assert "Unread One" in out + assert "Only Read" not in out + + +def test_cli_unread_when_no_unread_books_reports_empty(prepare_book_app, capsys, monkeypatch): + book_app, temp_file = prepare_book_app + + data = [ + {"title": "R1", "author": "A", "year": 2000, "read": True, "reviews": []}, + {"title": "R2", "author": "B", "year": 2001, "read": True, "reviews": []}, + ] + temp_file.write_text(json.dumps(data)) + book_app.collection.load_books() + + monkeypatch.setattr(sys, "argv", ["book_app.py", "unread"]) + book_app.main() + + out = capsys.readouterr().out + assert "No books found." in out