diff --git a/app.py b/app.py new file mode 100644 index 0000000..6a0c2a3 --- /dev/null +++ b/app.py @@ -0,0 +1,50 @@ +import os + +import click +import tornado.ioloop +import tornado.web + +from db import get_engine, get_session_factory, init_db +from handlers.main import MainHandler +from handlers.game import GameHandler +from handlers.api import ( + CategoriesHandler, + NewGameHandler, + GameStateHandler, + GuessHandler, +) + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def make_app(session_factory=None): + return tornado.web.Application( + [ + (r"/", MainHandler), + (r"/game", GameHandler), + (r"/api/categories", CategoriesHandler), + (r"/api/game/new", NewGameHandler), + (r"/api/game/([^/]+)", GameStateHandler), + (r"/api/game/([^/]+)/guess", GuessHandler), + ], + template_path=os.path.join(BASE_DIR, "templates"), + static_path=os.path.join(BASE_DIR, "static"), + session_factory=session_factory, + debug=True, + ) + + +@click.command() +@click.option("--port", default=8888, type=int, help="Port to listen on.") +def main(port): + engine = get_engine() + init_db(engine) + session_factory = get_session_factory(engine) + app = make_app(session_factory=session_factory) + app.listen(port) + print(f"Server started at http://localhost:{port}") + tornado.ioloop.IOLoop.current().start() + + +if __name__ == "__main__": + main() diff --git a/handlers/api.py b/handlers/api.py new file mode 100644 index 0000000..bf9a5c8 --- /dev/null +++ b/handlers/api.py @@ -0,0 +1,200 @@ +import json +from datetime import datetime, timezone + +import tornado.web + +from game.factory import BoardFactory, DIRECTIONS +from models.category import Category +from models.game import Game, Word + + +class BaseAPIHandler(tornado.web.RequestHandler): + def set_default_headers(self): + self.set_header("Content-Type", "application/json") + + def get_session(self): + Session = self.application.settings["session_factory"] + return Session() + + def write_error(self, status_code, **kwargs): + self.write(json.dumps({"error": self._reason})) + + +class CategoriesHandler(BaseAPIHandler): + def get(self): + session = self.get_session() + try: + categories = session.query(Category).all() + self.write(json.dumps([{"id": c.id, "name": c.name} for c in categories])) + finally: + session.close() + + +class NewGameHandler(BaseAPIHandler): + def post(self): + session = self.get_session() + try: + data = json.loads(self.request.body) + category_id = data["category_id"] + board_size = data.get("board_size", 10) + board_size = max(8, min(20, board_size)) + + factory = BoardFactory(session) + game, words = factory.create( + category_id=category_id, + board_size=board_size, + ) + session.add(game) + for w in words: + session.add(w) + session.commit() + + self.write(json.dumps(self._game_to_dict(game))) + finally: + session.close() + + def _game_to_dict(self, game): + return { + "id": game.id, + "board_size": game.board_size, + "category": game.category.name, + "grid": game.grid, + "words": [{"text": w.text, "found": w.found} for w in game.words], + "started_at": game.started_at.isoformat() + "Z", + "status": game.status, + "completed_at": None, + } + + +class GameStateHandler(BaseAPIHandler): + def get(self, game_id): + session = self.get_session() + try: + game = session.query(Game).filter_by(id=game_id).first() + if not game: + self.set_status(404) + self.write(json.dumps({"error": "Game not found"})) + return + self.write( + json.dumps( + { + "id": game.id, + "board_size": game.board_size, + "category": game.category.name, + "grid": game.grid, + "words": [ + {"text": w.text, "found": w.found} for w in game.words + ], + "started_at": game.started_at.isoformat() + "Z", + "status": game.status, + "completed_at": ( + game.completed_at.isoformat() + "Z" + if game.completed_at + else None + ), + } + ) + ) + finally: + session.close() + + +class GuessHandler(BaseAPIHandler): + def post(self, game_id): + session = self.get_session() + try: + game = session.query(Game).filter_by(id=game_id).first() + if not game: + self.set_status(404) + self.write(json.dumps({"error": "Game not found"})) + return + + data = json.loads(self.request.body) + + if "word" in data: + result = self._handle_text_guess(session, game, data["word"]) + elif "cells" in data: + result = self._handle_click_guess(session, game, data["cells"]) + else: + self.set_status(400) + self.write(json.dumps({"error": "Provide 'word' or 'cells'"})) + return + + self.write(json.dumps(result)) + finally: + session.close() + + def _handle_text_guess(self, session, game, guess_text): + guess_text = guess_text.upper().strip() + word = ( + session.query(Word) + .filter_by(game_id=game.id, text=guess_text, found=False) + .first() + ) + if not word: + return self._miss_response(game, session) + + return self._mark_found(session, game, word) + + def _handle_click_guess(self, session, game, cells): + grid = game.grid + try: + letters = "".join(grid[c["row"]][c["col"]] for c in cells) + except (IndexError, KeyError): + return self._miss_response(game, session) + + word = ( + session.query(Word) + .filter_by(game_id=game.id, text=letters, found=False) + .first() + ) + if not word: + return self._miss_response(game, session) + + dr, dc = DIRECTIONS[word.direction] + expected_cells = [ + {"row": word.start_row + i * dr, "col": word.start_col + i * dc} + for i in range(len(word.text)) + ] + if cells != expected_cells: + return self._miss_response(game, session) + + return self._mark_found(session, game, word) + + def _mark_found(self, session, game, word): + word.found = True + word.found_at = datetime.now(timezone.utc) + + unfound = session.query(Word).filter_by(game_id=game.id, found=False).count() + if unfound == 0: + game.status = "completed" + game.completed_at = datetime.now(timezone.utc) + + session.commit() + + dr, dc = DIRECTIONS[word.direction] + cells = [ + {"row": word.start_row + i * dr, "col": word.start_col + i * dc} + for i in range(len(word.text)) + ] + + return { + "correct": True, + "word": word.text, + "cells": cells, + "words": [{"text": w.text, "found": w.found} for w in game.words], + "status": game.status, + "completed_at": ( + game.completed_at.isoformat() + "Z" if game.completed_at else None + ), + } + + def _miss_response(self, game, session): + return { + "correct": False, + "word": None, + "cells": [], + "words": [{"text": w.text, "found": w.found} for w in game.words], + "status": game.status, + "completed_at": None, + } diff --git a/pyproject.toml b/pyproject.toml index e2e0b60..a79910f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ line-length = 88 target-version = ["py313"] [tool.setuptools.packages.find] -include = ["handlers*"] +include = ["handlers*", "models*", "game*"] [tool.setuptools_scm] version_scheme = "release-branch-semver" diff --git a/tests/conftest.py b/tests/conftest.py index f6b8ae7..80ae72d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,14 +3,30 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from models import Base +from models.category import Category, CategoryWord # noqa: F401 +from models.game import Game, Word # noqa: F401 @pytest.fixture -def db_session(): +def db_engine(): engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) - Session = sessionmaker(bind=engine) + yield engine + engine.dispose() + + +@pytest.fixture +def db_session(db_engine): + Session = sessionmaker(bind=db_engine) session = Session() yield session session.close() - engine.dispose() + + +@pytest.fixture +def app(db_engine): + from app import make_app + + Session = sessionmaker(bind=db_engine) + application = make_app(session_factory=Session) + return application diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py new file mode 100644 index 0000000..39da19f --- /dev/null +++ b/tests/e2e/test_api.py @@ -0,0 +1,112 @@ +import json + +import pytest + +from models.category import Category, CategoryWord + + +def _seed(db_session): + cat = Category(name="animals") + db_session.add(cat) + db_session.flush() + for w in ["TIGER", "LION", "BEAR", "WOLF", "EAGLE", "HAWK", "DEER", "FROG"]: + db_session.add(CategoryWord(category_id=cat.id, word=w)) + db_session.commit() + return cat + + +@pytest.fixture +def seeded_db(db_session): + return _seed(db_session) + + +async def test_get_categories(http_server_client, seeded_db): + resp = await http_server_client.fetch("/api/categories") + assert resp.code == 200 + data = json.loads(resp.body) + assert len(data) >= 1 + assert data[0]["name"] == "animals" + + +async def test_create_new_game(http_server_client, seeded_db): + body = json.dumps({"category_id": seeded_db.id, "board_size": 10}) + resp = await http_server_client.fetch( + "/api/game/new", + method="POST", + body=body, + headers={"Content-Type": "application/json"}, + ) + assert resp.code == 200 + data = json.loads(resp.body) + assert data["board_size"] == 10 + assert data["status"] == "in_progress" + assert len(data["grid"]) == 10 + assert len(data["words"]) >= 1 + # Words should NOT include position info + for w in data["words"]: + assert "start_row" not in w + assert "direction" not in w + + +async def test_get_game_state(http_server_client, seeded_db): + body = json.dumps({"category_id": seeded_db.id}) + create_resp = await http_server_client.fetch( + "/api/game/new", + method="POST", + body=body, + headers={"Content-Type": "application/json"}, + ) + game_id = json.loads(create_resp.body)["id"] + resp = await http_server_client.fetch(f"/api/game/{game_id}") + assert resp.code == 200 + data = json.loads(resp.body) + assert data["id"] == game_id + + +async def test_text_guess_correct(http_server_client, seeded_db): + body = json.dumps({"category_id": seeded_db.id}) + create_resp = await http_server_client.fetch( + "/api/game/new", + method="POST", + body=body, + headers={"Content-Type": "application/json"}, + ) + game_data = json.loads(create_resp.body) + game_id = game_data["id"] + # Pick a word that was placed + target_word = game_data["words"][0]["text"] + + guess_body = json.dumps({"word": target_word}) + resp = await http_server_client.fetch( + f"/api/game/{game_id}/guess", + method="POST", + body=guess_body, + headers={"Content-Type": "application/json"}, + ) + assert resp.code == 200 + data = json.loads(resp.body) + assert data["correct"] is True + assert data["word"] == target_word + assert "cells" in data + + +async def test_text_guess_incorrect(http_server_client, seeded_db): + body = json.dumps({"category_id": seeded_db.id}) + create_resp = await http_server_client.fetch( + "/api/game/new", + method="POST", + body=body, + headers={"Content-Type": "application/json"}, + ) + game_id = json.loads(create_resp.body)["id"] + + guess_body = json.dumps({"word": "ZZZZNOTAWORD"}) + resp = await http_server_client.fetch( + f"/api/game/{game_id}/guess", + method="POST", + body=guess_body, + headers={"Content-Type": "application/json"}, + ) + assert resp.code == 200 + data = json.loads(resp.body) + assert data["correct"] is False