Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions client/e2e-tests/games.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,21 @@ test.describe('Game Listing and Navigation', () => {
await expect(page).toHaveTitle(/Game Details - Tailspin Toys/);
});
});

test('should display search input and filter games', async ({ page }) => {
await test.step('Navigate to homepage and wait for games to load', async () => {
await page.goto('/');
await expect(page.getByTestId('games-grid')).toBeVisible();
});
Comment on lines +137 to +138
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses expect(...).toBeVisible() to assert presence. Per the Playwright test guidelines for this repo, avoid toBeVisible unless you’re specifically testing a visibility change; prefer presence/count assertions like toHaveCount(1) or toBeAttached().

This issue also appears on line 141 of the same file.

Copilot generated this review using guidance from repository custom instructions.

await test.step('Verify search and sort controls are visible', async () => {
await expect(page.getByTestId('game-search-input')).toBeVisible();
await expect(page.getByTestId('game-sort-select')).toBeVisible();
});

await test.step('Filter to no matching games', async () => {
await page.getByTestId('game-search-input').fill('zzzznonexistent');
await expect(page.getByTestId('game-card')).toHaveCount(0, { timeout: 10000 });
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid overriding assertion timeouts (timeout: 10000) per repo Playwright guidelines. Rely on Playwright’s auto-waiting; if the count can briefly be non-zero due to debounced fetches, consider asserting the network response or waiting for the games request to settle instead of increasing timeouts.

Copilot generated this review using guidance from repository custom instructions.
});
});
});
78 changes: 76 additions & 2 deletions client/src/components/GameList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,37 @@
description: string;
publisher_name?: string;
category_name?: string;
starRating?: number;
popularity?: number;
releaseDate?: string;
}

let { games = $bindable([]) }: { games?: Game[] } = $props();
let loading = $state(true);
let error = $state<string | null>(null);
let searchQuery = $state('');
let sortOption = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;

const fetchGames = async () => {
const sortOptions = [
{ value: '', label: 'Default' },
{ value: 'popularity', label: 'Popularity' },
{ value: 'release_date', label: 'Release Date' },
{ value: 'rating', label: 'User Rating' },
{ value: 'title', label: 'Title' },
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI offers both “Default” (empty sort) and an explicit “Title” sort, but the backend already defaults to ordering by title when sort is omitted. This makes the two options behave identically; consider removing one or making “Default” map to a distinct server default.

Suggested change
{ value: 'title', label: 'Title' },

Copilot uses AI. Check for mistakes.
];

const fetchGames = async (search: string = '', sort: string = '') => {
loading = true;
error = null;
try {
const response = await fetch('/api/games');
const params = new URLSearchParams();
if (search) params.set('search', search);
if (sort) params.set('sort', sort);

const queryString = params.toString();
const url = queryString ? `/api/games?${queryString}` : '/api/games';
const response = await fetch(url);
if(response.ok) {
games = await response.json();
} else {
Expand All @@ -33,13 +54,66 @@
}
};

const handleSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}

searchTimeout = setTimeout(() => {
fetchGames(searchQuery, sortOption);
}, 300);
};

const handleSort = () => {
fetchGames(searchQuery, sortOption);
};

onMount(() => {
fetchGames();

return () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
};
});
</script>

<div>
<h2 class="text-2xl font-medium mb-6 text-slate-100">Featured Games</h2>

<div class="mb-6">
<div class="flex flex-col sm:flex-row gap-4">
<div class="relative flex-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
<input
type="text"
bind:value={searchQuery}
oninput={handleSearch}
placeholder="Search featured games..."
class="w-full pl-10 pr-4 py-3 bg-slate-800/60 backdrop-blur-sm border border-slate-700/50 rounded-xl text-slate-100 placeholder-slate-400 focus:outline-none focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/50 transition-all duration-300"
data-testid="game-search-input"
/>
</div>
<div class="relative">
<select
bind:value={sortOption}
onchange={handleSort}
class="appearance-none w-full sm:w-48 px-4 py-3 bg-slate-800/60 backdrop-blur-sm border border-slate-700/50 rounded-xl text-slate-100 focus:outline-none focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/50 transition-all duration-300 cursor-pointer"
data-testid="game-sort-select"
>
{#each sortOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</div>
</div>

{#if loading}
<LoadingSkeleton count={6} />
Expand Down
Binary file modified data/tailspin-toys.db
Binary file not shown.
6 changes: 5 additions & 1 deletion server/models/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class Game(BaseModel):
title = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text, nullable=False)
star_rating = db.Column(db.Float, nullable=True)
popularity = db.Column(db.Integer, nullable=True, default=0)
release_date = db.Column(db.Date, nullable=True)
Comment on lines +12 to +13
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding new columns to the Game model won’t update existing SQLite databases because the app uses db.create_all() (which doesn’t ALTER existing tables). This can break environments that already have a tailspin-toys.db without these columns (queries will fail with missing-column errors). Consider adding a lightweight migration/recreate step (e.g., versioned migrations or a scripted drop/reseed path) instead of relying on an updated checked-in DB.

Copilot uses AI. Check for mistakes.

# Foreign keys for one-to-many relationships
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
Expand Down Expand Up @@ -38,5 +40,7 @@ def to_dict(self):
'description': self.description,
'publisher': {'id': self.publisher.id, 'name': self.publisher.name} if self.publisher else None,
'category': {'id': self.category.id, 'name': self.category.name} if self.category else None,
'starRating': self.star_rating # Changed from star_rating to starRating
'starRating': self.star_rating,
'popularity': self.popularity,
'releaseDate': self.release_date.isoformat() if self.release_date else None,
}
24 changes: 20 additions & 4 deletions server/routes/games.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
from flask import jsonify, Response, Blueprint
from flask import jsonify, request, Response, Blueprint
from models import db, Game, Publisher, Category
from sqlalchemy.orm import Query

# Create a Blueprint for games routes
games_bp = Blueprint('games', __name__)

SORT_OPTIONS: dict[str, tuple] = {
'popularity': (Game.popularity.desc(),),
'rating': (Game.star_rating.desc(),),
'release_date': (Game.release_date.desc(),),
'title': (Game.title.asc(),),
}

def get_games_base_query() -> Query:
return db.session.query(Game).join(
Publisher,
Expand All @@ -18,11 +25,20 @@ def get_games_base_query() -> Query:

@games_bp.route('/api/games', methods=['GET'])
def get_games() -> Response:
# Use the base query for all games
games_query = get_games_base_query().all()
games_query = get_games_base_query()

search = request.args.get('search', '').strip()
if search:
games_query = games_query.filter(Game.title.ilike(f'%{search}%'))

sort = request.args.get('sort', '').strip()
if sort in SORT_OPTIONS:
games_query = games_query.order_by(*SORT_OPTIONS[sort])
else:
games_query = games_query.order_by(Game.title.asc())

# Convert the results using the model's to_dict method
games_list = [game.to_dict() for game in games_query]
games_list = [game.to_dict() for game in games_query.all()]

return jsonify(games_list)

Expand Down
93 changes: 74 additions & 19 deletions server/tests/test_games.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import unittest
import json
from datetime import date
from typing import Dict, Any
from flask import Flask, Response
from models import Game, Publisher, Category, db
Expand All @@ -22,14 +23,18 @@ class TestGamesRoutes(unittest.TestCase):
"description": "Build your DevOps pipeline before chaos ensues",
"publisher_index": 0,
"category_index": 0,
"star_rating": 4.5
"star_rating": 4.5,
"popularity": 500,
"release_date": date(2025, 6, 15)
},
{
"title": "Agile Adventures",
"description": "Navigate your team through sprints and releases",
"publisher_index": 1,
"category_index": 1,
"star_rating": 4.2
"star_rating": 4.2,
"popularity": 800,
"release_date": date(2025, 9, 1)
}
]
}
Expand Down Expand Up @@ -112,17 +117,9 @@ def test_get_games_success(self) -> None:
# Assert
self.assertEqual(response.status_code, 200)
self.assertEqual(len(data), len(self.TEST_DATA["games"]))

# Verify all games using loop instead of manual testing
for i, game_data in enumerate(data):
test_game = self.TEST_DATA["games"][i]
test_publisher = self.TEST_DATA["publishers"][test_game["publisher_index"]]
test_category = self.TEST_DATA["categories"][test_game["category_index"]]

self.assertEqual(game_data['title'], test_game["title"])
self.assertEqual(game_data['publisher']['name'], test_publisher["name"])
self.assertEqual(game_data['category']['name'], test_category["name"])
self.assertEqual(game_data['starRating'], test_game["star_rating"])

titles = [game['title'] for game in data]
self.assertEqual(titles, sorted(titles))

def test_get_games_structure(self) -> None:
"""Test the response structure for games"""
Expand All @@ -135,7 +132,7 @@ def test_get_games_structure(self) -> None:
self.assertIsInstance(data, list)
self.assertEqual(len(data), len(self.TEST_DATA["games"]))

required_fields = ['id', 'title', 'description', 'publisher', 'category', 'starRating']
required_fields = ['id', 'title', 'description', 'publisher', 'category', 'starRating', 'popularity', 'releaseDate']
for field in required_fields:
self.assertIn(field, data[0])

Expand All @@ -145,18 +142,15 @@ def test_get_game_by_id_success(self) -> None:
response = self.client.get(self.GAMES_API_PATH)
games = self._get_response_data(response)
game_id = games[0]['id']
game_title = games[0]['title']

# Act
response = self.client.get(f'{self.GAMES_API_PATH}/{game_id}')
data = self._get_response_data(response)

# Assert
first_game = self.TEST_DATA["games"][0]
first_publisher = self.TEST_DATA["publishers"][first_game["publisher_index"]]

self.assertEqual(response.status_code, 200)
self.assertEqual(data['title'], first_game["title"])
self.assertEqual(data['publisher']['name'], first_publisher["name"])
self.assertEqual(data['title'], game_title)

def test_get_game_by_id_not_found(self) -> None:
"""Test retrieval of a non-existent game by ID"""
Expand Down Expand Up @@ -184,6 +178,67 @@ def test_get_games_empty_database(self) -> None:
self.assertIsInstance(data, list)
self.assertEqual(len(data), 0)

def test_search_games_by_title(self) -> None:
"""Test searching games by title returns matching results"""
response = self.client.get(f'{self.GAMES_API_PATH}?search=Pipeline')
data = self._get_response_data(response)

self.assertEqual(response.status_code, 200)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['title'], 'Pipeline Panic')

def test_search_games_case_insensitive(self) -> None:
"""Test that search is case insensitive"""
response = self.client.get(f'{self.GAMES_API_PATH}?search=pipeline')
data = self._get_response_data(response)

self.assertEqual(response.status_code, 200)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['title'], 'Pipeline Panic')

def test_search_games_no_results(self) -> None:
"""Test searching games with no matching results"""
response = self.client.get(f'{self.GAMES_API_PATH}?search=nonexistent')
data = self._get_response_data(response)

self.assertEqual(response.status_code, 200)
self.assertEqual(len(data), 0)

def test_sort_by_popularity(self) -> None:
"""Test sorting games by popularity descending"""
response = self.client.get(f'{self.GAMES_API_PATH}?sort=popularity')
data = self._get_response_data(response)

self.assertEqual(response.status_code, 200)
self.assertEqual(data[0]['title'], 'Agile Adventures')
self.assertEqual(data[1]['title'], 'Pipeline Panic')

def test_sort_by_rating(self) -> None:
"""Test sorting games by user rating descending"""
response = self.client.get(f'{self.GAMES_API_PATH}?sort=rating')
data = self._get_response_data(response)

self.assertEqual(response.status_code, 200)
self.assertEqual(data[0]['title'], 'Pipeline Panic')
self.assertEqual(data[1]['title'], 'Agile Adventures')

def test_sort_by_release_date(self) -> None:
"""Test sorting games by release date newest first"""
response = self.client.get(f'{self.GAMES_API_PATH}?sort=release_date')
data = self._get_response_data(response)

self.assertEqual(response.status_code, 200)
self.assertEqual(data[0]['title'], 'Agile Adventures')
self.assertEqual(data[1]['title'], 'Pipeline Panic')

def test_sort_invalid_option_falls_back_to_title_order(self) -> None:
"""Test invalid sort option falls back to title ordering"""
response = self.client.get(f'{self.GAMES_API_PATH}?sort=invalid')
data = self._get_response_data(response)

self.assertEqual(response.status_code, 200)
self.assertEqual([game['title'] for game in data], ['Agile Adventures', 'Pipeline Panic'])

def test_get_game_by_invalid_id_type(self) -> None:
"""Test retrieval of a game with invalid ID type"""
# Act
Expand Down
10 changes: 10 additions & 0 deletions server/utils/seed_database.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import csv
import os
import random
from datetime import date, timedelta
from flask import Flask
from models import db, Category, Game, Publisher
from utils.database import get_connection_string
Expand Down Expand Up @@ -67,6 +68,13 @@ def create_games():

# Generate random star rating between 3.0 and 5.0 (one decimal place)
star_rating = round(random.uniform(3.0, 5.0), 1)

# Generate random popularity score (0-10000)
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says popularity is generated in the 0–10000 range, but the code uses random.randint(100, 10000). Please either update the comment or adjust the randint bounds so they match.

Suggested change
# Generate random popularity score (0-10000)
# Generate random popularity score (100-10000)

Copilot uses AI. Check for mistakes.
popularity = random.randint(100, 10000)

# Generate random release date within the last 3 years
days_ago = random.randint(0, 3 * 365)
release_date = date.today() - timedelta(days=days_ago)

# Create the game with enhanced description for crowdfunding context
game = Game(
Expand All @@ -75,6 +83,8 @@ def create_games():
category_id=categories[category_name].id,
publisher_id=publishers[publisher_name].id,
star_rating=star_rating,
popularity=popularity,
release_date=release_date,
)
db.session.add(game)

Expand Down
Loading