From 0cab95b829adb10e1ca7275f93507238ebb77d5b Mon Sep 17 00:00:00 2001 From: MarcelloGasbarro Date: Fri, 2 Oct 2026 12:20:29 +0200 Subject: [PATCH] Improve book collection validation and search Add custom data-file support, validate new book details, and allow partial case-insensitive author searches across sample implementations. Add regression tests and apply Markdown formatting updates to the context chapter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- 02-context-conversations/README.md | 35 +++++++---- .../Services/BookCollection.cs | 33 +++++++++-- samples/book-app-project-js/books.js | 38 ++++++++++-- .../book-app-project-js/tests/test_books.js | 17 ++++++ samples/book-app-project/books.py | 59 +++++++++++++++---- samples/book-app-project/tests/test_books.py | 20 +++++++ 6 files changed, 170 insertions(+), 32 deletions(-) diff --git a/02-context-conversations/README.md b/02-context-conversations/README.md index 56a8222d..411d0bbc 100644 --- a/02-context-conversations/README.md +++ b/02-context-conversations/README.md @@ -82,7 +82,7 @@ copilot ### Basic @ Patterns | Pattern | What It Does | Example Use | -|---------|--------------|-------------| +| --- | --- | --- | | `@file.py` | Reference a single file | `Review @samples/book-app-project/books.py` | | `@folder/` | Reference all files in a directory | `Review @samples/book-app-project/` | | `@file1.py @file2.py` | Reference multiple files | `Compare @samples/book-app-project/book_app.py @samples/book-app-project/books.py` | @@ -475,10 +475,10 @@ No re-explaining. No re-reading files. Just continue working. Abstract crystal cave in blue and purple tones representing deeper exploration of context concepts -These topics build on the essentials above. **Pick what interests you, or skip ahead to [Practice](#practice).** +These topics build on the essentials above. **Pick what interests you, or skip ahead to** [**Practice**](#practice)**.** | I want to learn about... | Jump to | -|---|---| +| --- | --- | | Wildcard patterns and advanced session commands | [Additional @ Patterns & Session Commands](#additional-patterns) | | Building on context across multiple prompts | [Context-Aware Conversations](#context-aware-conversations) | | Token limits and `/compact` | [Understanding Context Windows](#understanding-context-windows) | @@ -486,7 +486,8 @@ These topics build on the essentials above. **Pick what interests you, or skip a | Analyzing screenshots and mockups | [Working with Images](#working-with-images) |
-Additional @ Patterns & Session Commands +Additional @ Patterns & Session Commands + ### Additional @ Patterns @@ -494,7 +495,7 @@ These topics build on the essentials above. **Pick what interests you, or skip a For power users, Copilot CLI supports wildcard patterns and image references: | Pattern | What It Does | -|---------|--------------| +| --- | --- | | `@folder/*.py` | All .py files in folder | | `@**/test_*.py` | Recursive wildcard: find all test files anywhere | | `@image.png` | Image file for UI review | @@ -537,6 +538,7 @@ copilot
Context-Aware Conversations + ### Context-Aware Conversations @@ -577,6 +579,7 @@ Notice how each prompt builds on the previous work. This is the power of context
Understanding Context Windows + ### Understanding Context Windows @@ -637,7 +640,7 @@ copilot #### Context Efficiency Tips | Situation | Action | Why | -|-----------|--------|-----| +| --- | --- | --- | | Starting new topic | `/clear` | Removes irrelevant context | | Went down wrong path | `/rewind` | Roll back conversation (and optionally restore files) to any earlier point | | Long conversation | `/compact` | Summarizes history, frees tokens | @@ -656,6 +659,7 @@ copilot
Choosing What to Reference + ### Choosing What to Reference @@ -665,13 +669,14 @@ Not all files are equal when it comes to context. Here's how to choose wisely: #### File Size Considerations | File Size | Approximate [Tokens](../GLOSSARY.md#token) | Strategy | -|-----------|-------------------|----------| +| --- | --- | --- | | Small (<100 lines) | ~500-1,500 tokens | Reference freely | | Medium (100-500 lines) | ~1,500-7,500 tokens | Reference specific files | | Large (500+ lines) | 7,500+ tokens | Be selective, use specific files | | Very Large (1000+ lines) | 15,000+ tokens | Consider splitting or targeting sections | **Concrete examples:** + - The book app's 4 Python files combined ≈ 2,000-3,000 tokens - A typical Python module (200 lines) ≈ 3,000 tokens - A Flask API file (400 lines) ≈ 6,000 tokens @@ -683,6 +688,7 @@ Not all files are equal when it comes to context. Here's how to choose wisely: #### What to Include vs. Exclude **High value** (include these): + - Entry points (`book_app.py`, `main.py`, `app.py`) - The specific files you're asking about - Files directly imported by your target file @@ -690,6 +696,7 @@ Not all files are equal when it comes to context. Here's how to choose wisely: - Data models or dataclasses **Lower value** (consider excluding): + - Generated files (compiled output, bundled assets) - Node modules or vendor directories - Large data files or fixtures @@ -706,11 +713,13 @@ Less specific ────────────────────── ``` **When to go broad** (`@samples/book-app-project/`): + - Initial codebase exploration - Finding patterns across many files - Architecture reviews **When to go specific** (`@samples/book-app-project/books.py`): + - Debugging a particular issue - Code review of a specific file - Asking about a single function @@ -739,6 +748,7 @@ This staged approach keeps context focused and efficient.
Working with Images + ### Working with Images @@ -847,6 +857,7 @@ The hands-on examples focused on code quality reviews and input validation. Now 💡 Hints (click to expand) **Getting started:** + ```bash cd /path/to/copilot-cli-for-beginners copilot @@ -859,6 +870,7 @@ copilot Then resume with: `copilot --continue` **Useful commands:** + - `@file.py` - Reference a single file - `@folder/` - Reference all files in a folder (note the trailing `/`) - `/context` - Check how much context you're using @@ -877,12 +889,12 @@ Then resume with: `copilot --continue` ---
-🔧 Common Mistakes & Troubleshooting (click to expand) +🔧 Common Mistakes & Troubleshooting (click to expand) ### Common Mistakes | Mistake | What Happens | Fix | -|---------|--------------|-----| +| --- | --- | --- | | Forgetting `@` before filenames | Copilot CLI treats "books.py" as plain text | Use `@samples/book-app-project/books.py` to reference files | | Expecting sessions to persist automatically | Starting `copilot` fresh loses all previous context | Use `--continue` (last session) or `--resume` (pick a session) | | Referencing files outside current directory | "Permission denied" or "File not found" errors | Use `/add-dir /path/to/directory` to grant access | @@ -912,6 +924,7 @@ copilot --add-dir /path/to/directory ``` **Context fills up too quickly**: + - Be more specific with file references - Use `/clear` between different topics - Split work across multiple sessions @@ -942,7 +955,7 @@ copilot --add-dir /path/to/directory Now that you can give Copilot CLI context, let's put it to work on real development tasks. The context techniques you just learned (file references, cross-file analysis, and session management) are the foundation for the powerful workflows in the next chapter. -In **[Chapter 03: Development Workflows](../03-development-workflows/README.md)**, you'll learn: +In [**Chapter 03: Development Workflows**](../03-development-workflows/README.md), you'll learn: - Code review workflows - Refactoring patterns @@ -952,4 +965,4 @@ In **[Chapter 03: Development Workflows](../03-development-workflows/README.md)* --- -**[← Back to Chapter 01](../01-setup-and-first-steps/README.md)** | **[Continue to Chapter 03 →](../03-development-workflows/README.md)** +[**← Back to Chapter 01**](../01-setup-and-first-steps/README.md) | [**Continue to Chapter 03 →**](../03-development-workflows/README.md) diff --git a/samples/book-app-project-cs/Services/BookCollection.cs b/samples/book-app-project-cs/Services/BookCollection.cs index b219ba53..a2c543e7 100644 --- a/samples/book-app-project-cs/Services/BookCollection.cs +++ b/samples/book-app-project-cs/Services/BookCollection.cs @@ -27,7 +27,8 @@ private void LoadBooks() try { var json = File.ReadAllText(_dataFile); - _books = JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + var books = JsonSerializer.Deserialize>(json, JsonOptions); + _books = books ?? []; } catch (FileNotFoundException) { @@ -42,13 +43,31 @@ private void LoadBooks() private void SaveBooks() { + var directory = Path.GetDirectoryName(_dataFile); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + var json = JsonSerializer.Serialize(_books, JsonOptions); File.WriteAllText(_dataFile, json); } public Book AddBook(string title, string author, int year) { - var book = new Book { Title = title, Author = author, Year = year }; + var cleanTitle = title.Trim(); + var cleanAuthor = author.Trim(); + if (string.IsNullOrWhiteSpace(cleanTitle) || string.IsNullOrWhiteSpace(cleanAuthor)) + { + throw new ArgumentException("Title and author are required."); + } + + if (year <= 0) + { + throw new ArgumentOutOfRangeException(nameof(year), "Year must be a positive integer."); + } + + var book = new Book { Title = cleanTitle, Author = cleanAuthor, Year = year }; _books.Add(book); SaveBooks(); return book; @@ -58,7 +77,7 @@ public Book AddBook(string title, string author, int year) public Book? FindBookByTitle(string title) { - return _books.Find(b => b.Title.Equals(title, StringComparison.OrdinalIgnoreCase)); + return _books.Find(b => b.Title.Equals(title.Trim(), StringComparison.OrdinalIgnoreCase)); } public bool MarkAsRead(string title) @@ -81,8 +100,14 @@ public bool RemoveBook(string title) public List FindByAuthor(string author) { + var normalized = author.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return []; + } + return _books - .Where(b => b.Author.Equals(author, StringComparison.OrdinalIgnoreCase)) + .Where(b => b.Author.Contains(normalized, StringComparison.OrdinalIgnoreCase)) .ToList(); } } diff --git a/samples/book-app-project-js/books.js b/samples/book-app-project-js/books.js index 85020b4b..99f8aed6 100644 --- a/samples/book-app-project-js/books.js +++ b/samples/book-app-project-js/books.js @@ -13,8 +13,8 @@ class Book { } class BookCollection { - constructor(dataFile) { - this.dataFile = dataFile || DATA_FILE; + constructor(dataFile = DATA_FILE) { + this.dataFile = dataFile; this.books = []; this.loadBooks(); } @@ -23,7 +23,13 @@ class BookCollection { try { const raw = fs.readFileSync(this.dataFile, "utf-8"); const data = JSON.parse(raw); - this.books = data.map((b) => new Book(b.title, b.author, b.year, b.read)); + if (!Array.isArray(data)) { + this.books = []; + return; + } + this.books = data + .filter((item) => item && typeof item === "object") + .map((b) => new Book(b.title, b.author, b.year, !!b.read)); } catch (err) { if (err.code === "ENOENT") { this.books = []; @@ -37,6 +43,10 @@ class BookCollection { } saveBooks() { + const dir = path.dirname(this.dataFile); + if (dir && !fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } const data = this.books.map((b) => ({ title: b.title, author: b.author, @@ -47,7 +57,18 @@ class BookCollection { } addBook(title, author, year) { - const book = new Book(title, author, year); + const cleanTitle = String(title).trim(); + const cleanAuthor = String(author).trim(); + if (!cleanTitle || !cleanAuthor) { + throw new Error("Title and author are required."); + } + + const numericYear = Number.parseInt(year, 10); + if (Number.isNaN(numericYear) || numericYear <= 0) { + throw new Error("Year must be a positive integer."); + } + + const book = new Book(cleanTitle, cleanAuthor, numericYear); this.books.push(book); this.saveBooks(); return book; @@ -58,7 +79,8 @@ class BookCollection { } findBookByTitle(title) { - return this.books.find((b) => b.title.toLowerCase() === title.toLowerCase()) || null; + const normalizedTitle = String(title).trim().toLowerCase(); + return this.books.find((b) => b.title.trim().toLowerCase() === normalizedTitle) || null; } markAsRead(title) { @@ -82,7 +104,11 @@ class BookCollection { } findByAuthor(author) { - return this.books.filter((b) => b.author.toLowerCase() === author.toLowerCase()); + const normalizedAuthor = String(author).trim().toLowerCase(); + if (!normalizedAuthor) { + return []; + } + return this.books.filter((b) => b.author.trim().toLowerCase().includes(normalizedAuthor)); } } diff --git a/samples/book-app-project-js/tests/test_books.js b/samples/book-app-project-js/tests/test_books.js index 446f68cb..328695b3 100644 --- a/samples/book-app-project-js/tests/test_books.js +++ b/samples/book-app-project-js/tests/test_books.js @@ -55,4 +55,21 @@ describe("BookCollection", () => { const result = collection.removeBook("Nonexistent Book"); assert.equal(result, false); }); + + it("should find books by partial author match", () => { + const collection = new BookCollection(tempFile); + collection.addBook("Dune", "Frank Herbert", 1965); + collection.addBook("The Hobbit", "J.R.R. Tolkien", 1937); + + const matches = collection.findByAuthor("tolkien"); + + assert.deepEqual(matches.map((book) => book.title), ["The Hobbit"]); + }); + + it("should validate required data before adding a book", () => { + const collection = new BookCollection(tempFile); + + assert.throws(() => collection.addBook(" ", "George Orwell", 1949), /Title and author are required/); + assert.throws(() => collection.addBook("1984", "George Orwell", 0), /Year must be a positive integer/); + }); }); diff --git a/samples/book-app-project/books.py b/samples/book-app-project/books.py index 2110689f..88e67872 100644 --- a/samples/book-app-project/books.py +++ b/samples/book-app-project/books.py @@ -1,6 +1,8 @@ import json +import os from dataclasses import dataclass, asdict -from typing import List, Optional +from pathlib import Path +from typing import List, Optional, Union DATA_FILE = "data.json" @@ -14,29 +16,57 @@ class Book: class BookCollection: - def __init__(self): + def __init__(self, data_file: Optional[Union[str, Path]] = None): + self.data_file = str(data_file) if data_file is not None else DATA_FILE self.books: List[Book] = [] self.load_books() - def load_books(self): + def load_books(self) -> None: """Load books from the JSON file if it exists.""" try: - with open(DATA_FILE, "r") as f: + with open(self.data_file, "r", encoding="utf-8") as f: data = json.load(f) - self.books = [Book(**b) for b in data] + if not isinstance(data, list): + self.books = [] + return + + self.books = [] + for item in data: + if not isinstance(item, dict): + continue + try: + self.books.append(Book(**item)) + except TypeError: + continue except FileNotFoundError: self.books = [] except json.JSONDecodeError: print("Warning: data.json is corrupted. Starting with empty collection.") self.books = [] - def save_books(self): + def save_books(self) -> None: """Save the current book collection to JSON.""" - with open(DATA_FILE, "w") as f: + directory = os.path.dirname(self.data_file) + if directory: + os.makedirs(directory, exist_ok=True) + with open(self.data_file, "w", encoding="utf-8") as f: json.dump([asdict(b) for b in self.books], f, indent=2) def add_book(self, title: str, author: str, year: int) -> Book: - book = Book(title=title, author=author, year=year) + cleaned_title = title.strip() + cleaned_author = author.strip() + if not cleaned_title or not cleaned_author: + raise ValueError("Title and author are required.") + + try: + year_value = int(year) + except (TypeError, ValueError) as exc: + raise ValueError("Year must be an integer.") from exc + + if year_value <= 0: + raise ValueError("Year must be a positive integer.") + + book = Book(title=cleaned_title, author=cleaned_author, year=year_value) self.books.append(book) self.save_books() return book @@ -45,8 +75,9 @@ def list_books(self) -> List[Book]: return self.books def find_book_by_title(self, title: str) -> Optional[Book]: + normalized_title = title.strip().lower() for book in self.books: - if book.title.lower() == title.lower(): + if book.title.strip().lower() == normalized_title: return book return None @@ -68,5 +99,11 @@ 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, including partial matches.""" + normalized_author = author.strip().lower() + if not normalized_author: + return [] + return [ + book for book in self.books + if normalized_author in book.author.strip().lower() + ] diff --git a/samples/book-app-project/tests/test_books.py b/samples/book-app-project/tests/test_books.py index 061149c5..2962e17b 100644 --- a/samples/book-app-project/tests/test_books.py +++ b/samples/book-app-project/tests/test_books.py @@ -51,3 +51,23 @@ def test_remove_book_invalid(): collection = BookCollection() result = collection.remove_book("Nonexistent Book") assert result is False + + +def test_find_by_author_partial_match(): + collection = BookCollection() + collection.add_book("Dune", "Frank Herbert", 1965) + collection.add_book("The Hobbit", "J.R.R. Tolkien", 1937) + + matches = collection.find_by_author("tolkien") + + assert [book.title for book in matches] == ["The Hobbit"] + + +def test_add_book_validates_required_fields(): + collection = BookCollection() + + with pytest.raises(ValueError, match="Title and author are required"): + collection.add_book(" ", "George Orwell", 1949) + + with pytest.raises(ValueError, match="Year must be a positive integer"): + collection.add_book("1984", "George Orwell", 0)