From 1928f0e3d4ee7b6de28ba454efe145726e73bc1d Mon Sep 17 00:00:00 2001 From: Chang Huan Date: Sun, 11 Jan 2026 22:33:21 -0800 Subject: [PATCH 1/2] Add Doordash app --- docs/apps/doordash.md | 126 ++++ docs/apps/index.md | 1 + pas/apps/__init__.py | 3 + pas/apps/doordash/__init__.py | 25 + pas/apps/doordash/app.py | 1014 ++++++++++++++++++++++++++++ pas/apps/doordash/states.py | 492 ++++++++++++++ tests/apps/test_doordash_states.py | 517 ++++++++++++++ 7 files changed, 2178 insertions(+) create mode 100644 docs/apps/doordash.md create mode 100644 pas/apps/doordash/__init__.py create mode 100644 pas/apps/doordash/app.py create mode 100644 pas/apps/doordash/states.py create mode 100644 tests/apps/test_doordash_states.py diff --git a/docs/apps/doordash.md b/docs/apps/doordash.md new file mode 100644 index 0000000..70f8474 --- /dev/null +++ b/docs/apps/doordash.md @@ -0,0 +1,126 @@ +# Stateful DoorDash App + +`pas.apps.doordash.app.StatefulDoordashApp` is a stateful DoorDash application that manages restaurant browsing, cart management, checkout, and order history with PAS navigation. +It launches in `RestaurantList` and transitions between restaurant browsing, menu viewing, cart management, checkout, and order tracking flows based on completed operations. + +--- + +## Navigation States + +--- + +### RestaurantList + +Home screen for browsing and searching restaurants, viewing cart, and accessing order history. + +| Tool | Backend call(s) | Returns | Navigation effect | +| --- | --- | --- | --- | +| `list_restaurants()` | `StatefulDoordashApp.list_restaurants()` | `list[dict[str, Any]]` restaurant summaries | Remains in `RestaurantList` | +| `search_restaurants(query: str)` | `StatefulDoordashApp.search_restaurants(query)` | `list[dict[str, Any]]` matching restaurants | Remains in `RestaurantList` | +| `open_restaurant(restaurant_id: str)` | `StatefulDoordashApp.get_restaurant(restaurant_id)` | `dict[str, Any]` restaurant details | → `RestaurantDetail(restaurant_id)` | +| `view_cart()` | `StatefulDoordashApp.get_cart()` | `dict[str, Any]` cart contents | → `CartView` | +| `view_orders()` | `StatefulDoordashApp.list_orders()` | `dict[str, Any]` orders list with pagination | → `OrderListView` | + +--- + +### RestaurantDetail + +Screen for viewing a specific restaurant and its menu items. + +| Tool | Backend call(s) | Returns | Navigation effect | +| --- | --- | --- | --- | +| `get_restaurant(restaurant_id: str)` | `StatefulDoordashApp.get_restaurant(restaurant_id)` | `dict[str, Any]` restaurant details | Remains in `RestaurantDetail` | +| `list_menu(restaurant_id: str)` | `StatefulDoordashApp.list_menu(restaurant_id)` | `list[dict[str, Any]]` menu items | Remains in `RestaurantDetail` | +| `search_menu_item(query: str)` | `StatefulDoordashApp.search_menu_item(query, restaurant_id)` | `list[dict[str, Any]]` matching menu items | Remains in `RestaurantDetail` | +| `open_menu_item(item_id: str)` | `StatefulDoordashApp.get_item(item_id)` | `dict[str, Any]` menu item details | → `MenuItemDetail(item_id, restaurant_id)` | +| `go_back()` | — | Navigation indicator string | → Previous state (via navigation stack) | + +--- + +### MenuItemDetail + +Screen for viewing a specific menu item with customization options. + +| Tool | Backend call(s) | Returns | Navigation effect | +| --- | --- | --- | --- | +| `get_item(item_id: str)` | `StatefulDoordashApp.get_item(item_id)` | `dict[str, Any]` menu item details | Remains in `MenuItemDetail` | +| `add_cart(item_id: str, quantity: int, customizations: dict[str, str] \| None = None)` | `StatefulDoordashApp.add_to_cart(item_id, quantity, customizations)` | `str` confirmation message | → `CartView` | +| `go_back()` | — | Navigation indicator string | → Previous state (via navigation stack) | + +--- + +### CartView + +Screen for viewing and managing the shopping cart. + +| Tool | Backend call(s) | Returns | Navigation effect | +| --- | --- | --- | --- | +| `get_cart()` | `StatefulDoordashApp.get_cart()` | `dict[str, Any]` cart contents with items and total | Remains in `CartView` | +| `update_cart(item_id: str, quantity: int)` | `StatefulDoordashApp.update_cart(item_id, quantity)` | `str` confirmation message | Remains in `CartView` | +| `remove_from_cart(item_id: str)` | `StatefulDoordashApp.remove_from_cart(item_id)` | `str` confirmation message | Remains in `CartView` | +| `clear_cart()` | `StatefulDoordashApp.clear_cart()` | `str` confirmation message | Remains in `CartView` | +| `checkout()` | Reads cart, delivery address, and payment method | `dict[str, Any]` checkout info | → `CheckoutView` | +| `go_back()` | — | Navigation indicator string | → Previous state (via navigation stack) | + +--- + +### CheckoutView + +Screen for checkout and order placement. + +| Tool | Backend call(s) | Returns | Navigation effect | +| --- | --- | --- | --- | +| `set_address(address: str)` | `StatefulDoordashApp.set_delivery_address(address)` | `str` confirmation message | Remains in `CheckoutView` | +| `set_payment(method: str)` | `StatefulDoordashApp.set_payment_method(method)` | `str` confirmation message | Remains in `CheckoutView` | +| `submit_order()` | `StatefulDoordashApp.place_order()` | `str` order ID | → `OrderDetail(order_id)` | +| `go_back()` | — | Navigation indicator string | → Previous state (via navigation stack) | + +--- + +### OrderListView + +Screen for viewing order history. + +| Tool | Backend call(s) | Returns | Navigation effect | +| --- | --- | --- | --- | +| `list_orders()` | `StatefulDoordashApp.list_orders()` | `dict[str, Any]` orders list with pagination | Remains in `OrderListView` | +| `open_order(order_id: str)` | `StatefulDoordashApp.get_order(order_id)` | `dict[str, Any]` order details | → `OrderDetail(order_id)` | +| `go_back()` | — | Navigation indicator string | → Previous state (via navigation stack) | + +--- + +### OrderDetail + +Screen for viewing a specific order's details. + +| Tool | Backend call(s) | Returns | Navigation effect | +| --- | --- | --- | --- | +| `get_order(order_id: str)` | `StatefulDoordashApp.get_order(order_id)` | `dict[str, Any]` complete order details | Remains in `OrderDetail` | +| `cancel_order(order_id: str)` | `StatefulDoordashApp.cancel_order(order_id)` | `str` confirmation message | Remains in `OrderDetail` | +| `reorder_order(order_id: str)` | `StatefulDoordashApp.reorder(order_id)` | `str` confirmation message | → `CartView` | +| `go_back()` | — | Navigation indicator string | → Previous state (via navigation stack) | + +--- + +## Summary Table + +| State | Context | Transitions Out | Self-Loops | +|-------|---------|-----------------|------------| +| **RestaurantList** | — | `open_restaurant` → RestaurantDetail, `view_cart` → CartView, `view_orders` → OrderListView | `list_restaurants`, `search_restaurants` | +| **RestaurantDetail** | restaurant_id | `open_menu_item` → MenuItemDetail, `go_back` → previous state | `get_restaurant`, `list_menu`, `search_menu_item` | +| **MenuItemDetail** | item_id, restaurant_id | `add_cart` → CartView, `go_back` → previous state | `get_item` | +| **CartView** | — | `checkout` → CheckoutView, `go_back` → previous state | `get_cart`, `update_cart`, `remove_from_cart`, `clear_cart` | +| **CheckoutView** | — | `submit_order` → OrderDetail, `go_back` → previous state | `set_address`, `set_payment` | +| **OrderListView** | — | `open_order` → OrderDetail, `go_back` → previous state | `list_orders` | +| **OrderDetail** | order_id | `reorder_order` → CartView, `go_back` → previous state | `get_order`, `cancel_order` | + +--- + +## Navigation Helpers + +- Navigation transitions are handled in `StatefulDoordashApp.handle_state_transition` based on the completed tool name. +- States store context parameters (e.g., `restaurant_id`, `item_id`, `order_id`) for navigation. +- Cart can only contain items from a single restaurant at a time. +- After order submission, the cart is automatically cleared. +- `go_back()` is inherited from `StatefulApp` and uses the navigation stack to return to the previous state. +- Order cancellation can only be performed on orders with status "placed", "preparing", or "delivering" (not "delivered" or "cancelled"). diff --git a/docs/apps/index.md b/docs/apps/index.md index 65f14a9..37817bc 100644 --- a/docs/apps/index.md +++ b/docs/apps/index.md @@ -13,6 +13,7 @@ Use these pages to explore the current user-facing tool surface for each PAS sta - [Stateful Reminder App](./reminder.md) - [Stateful Shopping App](./shopping.md) - [Stateful Note App](./notes.md) +- [Stateful DoorDash App](./doordash.md) ## Navigation Framework Recap diff --git a/pas/apps/__init__.py b/pas/apps/__init__.py index c2288be..a549988 100644 --- a/pas/apps/__init__.py +++ b/pas/apps/__init__.py @@ -9,6 +9,7 @@ from pas.apps.calendar.app import StatefulCalendarApp from pas.apps.contacts.app import StatefulContactsApp from pas.apps.core import AppState, StatefulApp +from pas.apps.doordash import StatefulDoordashApp from pas.apps.email.app import StatefulEmailApp from pas.apps.messaging.app import StatefulMessagingApp from pas.apps.note import StatefulNotesApp @@ -28,6 +29,7 @@ "StatefulCabApp", "StatefulCalendarApp", "StatefulContactsApp", + "StatefulDoordashApp", "StatefulEmailApp", "StatefulMessagingApp", "StatefulNotesApp", @@ -48,4 +50,5 @@ StatefulMessagingApp, StatefulNotesApp, StatefulReminderApp, + StatefulDoordashApp, ] diff --git a/pas/apps/doordash/__init__.py b/pas/apps/doordash/__init__.py new file mode 100644 index 0000000..4109cc7 --- /dev/null +++ b/pas/apps/doordash/__init__.py @@ -0,0 +1,25 @@ +"""Stateful doordash application package.""" + +from __future__ import annotations + +from pas.apps.doordash.app import StatefulDoordashApp +from pas.apps.doordash.states import ( + CartView, + CheckoutView, + MenuItemDetail, + OrderDetail, + OrderListView, + RestaurantDetail, + RestaurantList, +) + +__all__ = [ + "CartView", + "CheckoutView", + "MenuItemDetail", + "OrderDetail", + "OrderListView", + "RestaurantDetail", + "RestaurantList", + "StatefulDoordashApp", +] diff --git a/pas/apps/doordash/app.py b/pas/apps/doordash/app.py new file mode 100644 index 0000000..90e8040 --- /dev/null +++ b/pas/apps/doordash/app.py @@ -0,0 +1,1014 @@ +"""Stateful DoorDash app with PAS navigation.""" + +from __future__ import annotations + +import contextlib +import logging +import textwrap +from copy import deepcopy +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from are.simulation.tool_utils import OperationType, app_tool, data_tool +from are.simulation.types import EventType +from are.simulation.utils import get_state_dict, uuid_hex +from are.simulation.utils.type_utils import type_check + +if TYPE_CHECKING: + from are.simulation.types import CompletedEvent + +from pas.apps.core import StatefulApp +from pas.apps.doordash.states import ( + CartView, + CheckoutView, + MenuItemDetail, + OrderDetail, + OrderListView, + RestaurantDetail, + RestaurantList, +) +from pas.apps.tool_decorators import pas_event_registered + +logger = logging.getLogger(__name__) + + +@dataclass +class MenuItem: + """Menu item model. + + Represents a purchasable item belonging to a restaurant. + """ + + item_id: str + name: str + price: float + restaurant_id: str + description: str = "" + category: str = "" + available: bool = True + customizations: dict[str, list[str]] = field(default_factory=dict) + + def __str__(self) -> str: + return textwrap.dedent( + f""" + ID: {self.item_id} + Name: {self.name} + Price: ${self.price:.2f} + Restaurant: {self.restaurant_id} + Description: {self.description} + Category: {self.category} + Available: {self.available} + """ + ) + + def get_state(self) -> dict[str, Any]: + """Serialize menu item state.""" + return get_state_dict( + self, + ["item_id", "name", "price", "restaurant_id", "description", "category", "available", "customizations"], + ) + + def load_state(self, state_dict: dict[str, Any]) -> None: + """Restore menu item from serialized state.""" + self.item_id = state_dict["item_id"] + self.name = state_dict["name"] + self.price = state_dict["price"] + self.restaurant_id = state_dict["restaurant_id"] + self.description = state_dict.get("description", "") + self.category = state_dict.get("category", "") + self.available = state_dict.get("available", True) + self.customizations = state_dict.get("customizations", {}) + + +@dataclass +class Restaurant: + """Restaurant model with menu references.""" + + restaurant_id: str + name: str + cuisine: str + rating: float = 0.0 + delivery_time: int = 30 + menu_items: list[str] = field(default_factory=list) + + def __str__(self) -> str: + return textwrap.dedent( + f""" + ID: {self.restaurant_id} + Name: {self.name} + Cuisine: {self.cuisine} + Rating: {self.rating}/5.0 + Delivery Time: {self.delivery_time} min + Menu Items: {len(self.menu_items)} + """ + ) + + def get_state(self) -> dict[str, Any]: + """Serialize restaurant state.""" + return get_state_dict(self, ["restaurant_id", "name", "cuisine", "rating", "delivery_time", "menu_items"]) + + def load_state(self, state_dict: dict[str, Any]) -> None: + """Restore restaurant from serialized state.""" + self.restaurant_id = state_dict["restaurant_id"] + self.name = state_dict["name"] + self.cuisine = state_dict["cuisine"] + self.rating = state_dict.get("rating", 0.0) + self.delivery_time = state_dict.get("delivery_time", 30) + self.menu_items = state_dict.get("menu_items", []) + + +@dataclass +class CartItem: + """Item stored in the active shopping cart.""" + + item_id: str + name: str + quantity: int + price: float + customizations: dict[str, str] = field(default_factory=dict) + + +@dataclass +class Order: + """Placed order snapshot.""" + + order_id: str + restaurant_id: str + restaurant_name: str + order_status: str + order_date: datetime | float + order_total: float + delivery_address: str + payment_method: str + order_items: dict[str, CartItem] = field(default_factory=dict) + + def __str__(self) -> str: + order_date_str = ( + datetime.fromtimestamp(self.order_date, tz=UTC).strftime("%Y-%m-%d %H:%M:%S") + if isinstance(self.order_date, (int, float)) + else str(self.order_date) + ) + return textwrap.dedent( + f""" + Order ID: {self.order_id} + Restaurant: {self.restaurant_name} + Status: {self.order_status} + Total: ${self.order_total:.2f} + Address: {self.delivery_address} + Payment: {self.payment_method} + Date: {order_date_str} + Items: {len(self.order_items)} + """ + ) + + def get_state(self) -> dict[str, Any]: + """Serialize order state.""" + return { + "order_id": self.order_id, + "restaurant_id": self.restaurant_id, + "restaurant_name": self.restaurant_name, + "order_status": self.order_status, + "order_date": self.order_date.isoformat() if isinstance(self.order_date, datetime) else self.order_date, + "order_total": self.order_total, + "delivery_address": self.delivery_address, + "payment_method": self.payment_method, + "order_items": { + k: { + "item_id": v.item_id, + "name": v.name, + "quantity": v.quantity, + "price": v.price, + "customizations": v.customizations, + } + for k, v in self.order_items.items() + }, + } + + def load_state(self, state_dict: dict[str, Any]) -> None: + """Restore order from serialized state.""" + self.order_id = state_dict["order_id"] + self.restaurant_id = state_dict["restaurant_id"] + self.restaurant_name = state_dict["restaurant_name"] + self.order_status = state_dict["order_status"] + self.order_total = state_dict["order_total"] + self.delivery_address = state_dict["delivery_address"] + self.payment_method = state_dict["payment_method"] + + if isinstance(state_dict["order_date"], str): + try: + self.order_date = datetime.fromisoformat(state_dict["order_date"]) + except ValueError: + # Try to convert string to float (timestamp) + try: + self.order_date = float(state_dict["order_date"]) + except (ValueError, TypeError): + # Fallback to current timestamp if conversion fails + self.order_date = datetime.now(UTC).timestamp() + else: + self.order_date = state_dict["order_date"] + + self.order_items = {} + for item_id, item_data in state_dict.get("order_items", {}).items(): + self.order_items[item_id] = CartItem(**item_data) + + +@dataclass +class StatefulDoordashApp(StatefulApp): + """A DoorDash application that manages restaurant browsing, cart management, checkout, and order history with state-aware transitions. + + Key Features: + - Restaurant Management: Browse, search, and view restaurant details + - Menu Management: View and search menu items with customization options + - Cart Management: Add, update, remove items with quantity control + - Order Management: Place orders, track status, view history, cancel and reorder + - Checkout Flow: Set delivery address and payment method + - State Management: Save and load application state + + Notes: + - Cart can only contain items from a single restaurant at a time + - Order IDs are automatically generated when placing orders + - All monetary values are in USD + """ + + name: str | None = None + restaurants: dict[str, Restaurant] = field(default_factory=dict) + menu_items: dict[str, MenuItem] = field(default_factory=dict) + cart: dict[str, CartItem] = field(default_factory=dict) + orders: dict[str, Order] = field(default_factory=dict) + delivery_address: str = "" + payment_method: str = "" + + def __post_init__(self) -> None: + """Initialize the DoorDash app.""" + super().__init__(self.name or "doordash") + self.load_root_state() + + def create_root_state(self) -> RestaurantList: + """Create the root navigation state.""" + return RestaurantList() + + def reset(self) -> None: + """Reset the app to empty state.""" + super().reset() + self.restaurants.clear() + self.menu_items.clear() + self.cart.clear() + self.orders.clear() + self.delivery_address = "" + self.payment_method = "" + + def _get_restaurant_by_id(self, restaurant_id: str) -> Restaurant: + """Get restaurant by ID with validation.""" + if restaurant_id not in self.restaurants: + raise KeyError(f"Restaurant {restaurant_id} not found") + return self.restaurants[restaurant_id] + + def _get_menu_item_by_id(self, item_id: str) -> MenuItem: + """Get menu item by ID with validation.""" + if item_id not in self.menu_items: + raise KeyError(f"Menu item {item_id} not found") + return self.menu_items[item_id] + + def _validate_cart_restaurant_consistency(self) -> str | None: + """Validate that all cart items are from the same restaurant.""" + if not self.cart: + return None + + restaurant_ids = set() + for item_id in self.cart: + menu_item = self.menu_items.get(item_id) + if not menu_item: + raise ValueError(f"Cart contains invalid item: {item_id}") + restaurant_ids.add(menu_item.restaurant_id) + + if len(restaurant_ids) > 1: + raise ValueError("Cart contains items from multiple restaurants") + + return restaurant_ids.pop() if restaurant_ids else None + + @type_check + @data_tool() + @pas_event_registered(operation_type=OperationType.WRITE) + def create_restaurant_with_menu( + self, + name: str, + cuisine: str, + rating: float = 0.0, + delivery_time: int = 30, + menu_items_data: list[dict[str, Any]] | None = None, + ) -> str: + """Create a restaurant with initial menu items.""" + if not isinstance(rating, (int, float)): + raise TypeError(f"Rating must be a number, got {type(rating)}.") + if rating < 0.0 or rating > 5.0: + raise ValueError("Rating must be between 0.0 and 5.0") + if not isinstance(delivery_time, int): + raise TypeError(f"Delivery time must be an integer, got {type(delivery_time)}.") + if delivery_time < 0: + raise ValueError("Delivery time must be non-negative") + + restaurant_id = uuid_hex(self.rng) + restaurant = Restaurant( + restaurant_id=restaurant_id, + name=name, + cuisine=cuisine, + rating=rating, + delivery_time=delivery_time, + menu_items=[], + ) + + if menu_items_data: + for item_data in menu_items_data: + item_id = uuid_hex(self.rng) + menu_item = MenuItem( + item_id=item_id, + restaurant_id=restaurant_id, + name=item_data.get("name", ""), + price=item_data.get("price", 0.0), + description=item_data.get("description", ""), + category=item_data.get("category", ""), + available=item_data.get("available", True), + customizations=item_data.get("customizations", {}), + ) + self.menu_items[item_id] = menu_item + restaurant.menu_items.append(item_id) + + self.restaurants[restaurant_id] = restaurant + return restaurant_id + + @type_check + @data_tool() + @pas_event_registered(operation_type=OperationType.WRITE) + def create_order_with_time( + self, + restaurant_id: str, + items: list[dict[str, Any]], + delivery_address: str, + payment_method: str, + order_date: str, + order_status: str = "placed", + ) -> str: + """Create an order with specific timestamp.""" + try: + order_timestamp = datetime.strptime(order_date, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp() + except ValueError as e: + raise ValueError("Invalid datetime format. Use YYYY-MM-DD HH:MM:SS") from e + + restaurant = self._get_restaurant_by_id(restaurant_id) + + order_id = uuid_hex(self.rng) + order_items = {} + total = 0.0 + + for item_data in items: + item_id = item_data.get("item_id", uuid_hex(self.rng)) + cart_item = CartItem( + item_id=item_id, + name=item_data.get("name", ""), + quantity=item_data.get("quantity", 1), + price=item_data.get("price", 0.0), + customizations=item_data.get("customizations", {}), + ) + order_items[item_id] = cart_item + total += cart_item.price * cart_item.quantity + + order = Order( + order_id=order_id, + restaurant_id=restaurant_id, + restaurant_name=restaurant.name, + order_status=order_status, + order_date=order_timestamp, + order_total=total, + delivery_address=delivery_address, + payment_method=payment_method, + order_items=order_items, + ) + + self.orders[order_id] = order + return order_id + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT) + def list_restaurants(self) -> list[dict[str, Any]]: + """List all available restaurants.""" + return [ + { + "restaurant_id": r.restaurant_id, + "name": r.name, + "cuisine": r.cuisine, + "rating": r.rating, + "delivery_time": r.delivery_time, + } + for r in self.restaurants.values() + ] + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT) + def search_restaurants(self, query: str) -> list[dict[str, Any]]: + """Search restaurants by name or cuisine.""" + if not isinstance(query, str): + raise TypeError(f"Query must be a string, got {type(query)}.") + if len(query.strip()) == 0: + raise ValueError("Query must be non-empty.") + + query_lower = query.lower() + return [ + { + "restaurant_id": r.restaurant_id, + "name": r.name, + "cuisine": r.cuisine, + "rating": r.rating, + "delivery_time": r.delivery_time, + } + for r in self.restaurants.values() + if query_lower in r.name.lower() or query_lower in r.cuisine.lower() + ] + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT) + def get_restaurant(self, restaurant_id: str) -> dict[str, Any]: + """Get details of a specific restaurant.""" + if not isinstance(restaurant_id, str): + raise TypeError(f"Restaurant ID must be a string, got {type(restaurant_id)}.") + if len(restaurant_id) == 0: + raise ValueError("Restaurant ID must be non-empty.") + + restaurant = self._get_restaurant_by_id(restaurant_id) + + return { + "restaurant_id": restaurant.restaurant_id, + "name": restaurant.name, + "cuisine": restaurant.cuisine, + "rating": restaurant.rating, + "delivery_time": restaurant.delivery_time, + "menu_items": restaurant.menu_items, + } + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT) + def list_menu(self, restaurant_id: str) -> list[dict[str, Any]]: + """List menu items for a specific restaurant.""" + if not isinstance(restaurant_id, str): + raise TypeError(f"Restaurant ID must be a string, got {type(restaurant_id)}.") + if len(restaurant_id) == 0: + raise ValueError("Restaurant ID must be non-empty.") + + restaurant = self._get_restaurant_by_id(restaurant_id) + + return [ + { + "item_id": self.menu_items[item_id].item_id, + "name": self.menu_items[item_id].name, + "price": self.menu_items[item_id].price, + "category": self.menu_items[item_id].category, + "available": self.menu_items[item_id].available, + } + for item_id in restaurant.menu_items + if item_id in self.menu_items + ] + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT) + def search_menu_item(self, query: str, restaurant_id: str | None = None) -> list[dict[str, Any]]: + """Search menu items by name or description.""" + if not isinstance(query, str): + raise TypeError(f"Query must be a string, got {type(query)}.") + if len(query.strip()) == 0: + raise ValueError("Query must be non-empty.") + + query_lower = query.lower() + items_to_search = [] + + if restaurant_id is not None: + restaurant = self._get_restaurant_by_id(restaurant_id) + items_to_search = [ + self.menu_items[item_id] for item_id in restaurant.menu_items if item_id in self.menu_items + ] + else: + items_to_search = list(self.menu_items.values()) + + return [ + { + "item_id": item.item_id, + "name": item.name, + "price": item.price, + "description": item.description, + "category": item.category, + "restaurant_id": item.restaurant_id, + "available": item.available, + } + for item in items_to_search + if query_lower in item.name.lower() or query_lower in item.description.lower() + ] + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT) + def get_item(self, item_id: str) -> dict[str, Any]: + """Get detailed information about a menu item.""" + if not isinstance(item_id, str): + raise TypeError(f"Item ID must be a string, got {type(item_id)}.") + if len(item_id) == 0: + raise ValueError("Item ID must be non-empty.") + + item = self._get_menu_item_by_id(item_id) + + return { + "item_id": item.item_id, + "name": item.name, + "price": item.price, + "description": item.description, + "category": item.category, + "available": item.available, + "customizations": item.customizations, + "restaurant_id": item.restaurant_id, + } + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT) + def add_to_cart(self, item_id: str, quantity: int = 1, customizations: dict[str, str] | None = None) -> str: + """Add an item to the shopping cart.""" + if not isinstance(item_id, str): + raise TypeError(f"Item ID must be a string, got {type(item_id)}.") + if len(item_id) == 0: + raise ValueError("Item ID must be non-empty.") + if not isinstance(quantity, int): + raise TypeError(f"Quantity must be an integer, got {type(quantity)}.") + if quantity <= 0: + raise ValueError("Quantity must be positive") + + item = self._get_menu_item_by_id(item_id) + + if not item.available: + raise ValueError(f"Item {item.name} is not available") + + if self.cart: + current_restaurant = self._validate_cart_restaurant_consistency() + if current_restaurant and current_restaurant != item.restaurant_id: + raise ValueError( + f"Cannot add item from different restaurant. Cart contains items from restaurant {current_restaurant}" + ) + + if item_id in self.cart: + self.cart[item_id].quantity += quantity + else: + self.cart[item_id] = CartItem( + item_id=item_id, + name=item.name, + quantity=quantity, + price=item.price, + customizations=customizations or {}, + ) + return f"Added {quantity}x {item.name} to cart" + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT) + def update_cart(self, item_id: str, quantity: int) -> str: + """Update the quantity of an item in the cart.""" + if not isinstance(item_id, str): + raise TypeError(f"Item ID must be a string, got {type(item_id)}.") + if len(item_id) == 0: + raise ValueError("Item ID must be non-empty.") + if not isinstance(quantity, int): + raise TypeError(f"Quantity must be an integer, got {type(quantity)}.") + + if item_id not in self.cart: + raise KeyError(f"Item {item_id} not in cart") + + if quantity == 0: + name = self.cart[item_id].name + del self.cart[item_id] + return f"Removed {name} from cart" + elif quantity > 0: + self.cart[item_id].quantity = quantity + return f"Updated quantity to {quantity}" + else: + raise ValueError("Quantity cannot be negative") + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT) + def remove_from_cart(self, item_id: str) -> str: + """Remove an item from the cart.""" + if not isinstance(item_id, str): + raise TypeError(f"Item ID must be a string, got {type(item_id)}.") + if len(item_id) == 0: + raise ValueError("Item ID must be non-empty.") + + if item_id not in self.cart: + raise KeyError(f"Item {item_id} not in cart") + + name = self.cart[item_id].name + del self.cart[item_id] + return f"Removed {name} from cart" + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT) + def clear_cart(self) -> str: + """Clear all items from the cart.""" + count = len(self.cart) + self.cart.clear() + return f"Removed {count} items from cart" + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT) + def get_cart(self) -> dict[str, Any]: + """Get current cart contents and total.""" + total = sum(item.price * item.quantity for item in self.cart.values()) + return { + "items": [ + { + "item_id": item.item_id, + "name": item.name, + "quantity": item.quantity, + "price": item.price, + "customizations": item.customizations, + } + for item in self.cart.values() + ], + "total": total, + } + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT) + def set_delivery_address(self, address: str) -> str: + """Set the delivery address for the order.""" + if not isinstance(address, str): + raise TypeError(f"Address must be a string, got {type(address)}.") + if len(address.strip()) == 0: + raise ValueError("Address must be non-empty.") + + self.delivery_address = address + return f"Delivery address set to: {address}" + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT) + def set_payment_method(self, method: str) -> str: + """Set the payment method for the order.""" + if not isinstance(method, str): + raise TypeError(f"Payment method must be a string, got {type(method)}.") + if len(method.strip()) == 0: + raise ValueError("Payment method must be non-empty.") + + self.payment_method = method + return f"Payment method set to: {method}" + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT) + def place_order(self) -> str: + """Place an order with current cart contents.""" + if not self.cart: + raise ValueError("Cart is empty") + if not self.delivery_address or not self.delivery_address.strip(): + raise ValueError("Delivery address not set") + if not self.payment_method or not self.payment_method.strip(): + raise ValueError("Payment method not set") + + restaurant_id = self._validate_cart_restaurant_consistency() + if restaurant_id is None: + raise ValueError("Cannot determine restaurant for order") + restaurant = self.restaurants.get(restaurant_id) + if restaurant is None: + raise KeyError(f"Restaurant {restaurant_id} no longer exists") + + unavailable_items = [] + for item_id, cart_item in self.cart.items(): + menu_item = self.menu_items.get(item_id) + if menu_item is None: + unavailable_items.append(f"{cart_item.name} (removed from menu)") + elif not menu_item.available: + unavailable_items.append(f"{cart_item.name} (no longer available)") + + if unavailable_items: + raise ValueError(f"Cannot place order. The following items are unavailable: {', '.join(unavailable_items)}") + + order_id = uuid_hex(self.rng) + total = sum(item.price * item.quantity for item in self.cart.values()) + + order = Order( + order_id=order_id, + restaurant_id=restaurant_id, + restaurant_name=restaurant.name, + order_status="placed", + order_date=self.time_manager.time(), + order_total=total, + delivery_address=self.delivery_address, + payment_method=self.payment_method, + order_items=deepcopy(self.cart), + ) + + self.orders[order_id] = order + self.cart.clear() + return order_id + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT) + def list_orders(self, limit: int = 10, offset: int = 0) -> dict[str, Any]: + """List orders with pagination.""" + if not isinstance(limit, int): + raise TypeError(f"Limit must be an integer, got {type(limit)}.") + if not isinstance(offset, int): + raise TypeError(f"Offset must be an integer, got {type(offset)}.") + if limit <= 0: + raise ValueError("Limit must be positive") + if offset < 0: + raise ValueError("Offset must be non-negative") + + sorted_orders = sorted( + self.orders.values(), + key=lambda o: o.order_date if isinstance(o.order_date, (int, float)) else 0, + reverse=True, + ) + + total = len(sorted_orders) + end = min(offset + limit, total) + paginated_orders = sorted_orders[offset:end] + + return { + "orders": [ + { + "order_id": order.order_id, + "restaurant_name": order.restaurant_name, + "total": order.order_total, + "status": order.order_status, + "created_at": order.order_date, + } + for order in paginated_orders + ], + "total_orders": total, + "offset": offset, + "limit": limit, + } + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.READ, event_type=EventType.AGENT) + def get_order(self, order_id: str) -> dict[str, Any]: + """Get complete details of a specific order.""" + if not isinstance(order_id, str): + raise TypeError(f"Order ID must be a string, got {type(order_id)}.") + if len(order_id) == 0: + raise ValueError("Order ID must be non-empty.") + + order = self.orders.get(order_id) + if order is None: + raise KeyError(f"Order {order_id} not found") + + return { + "order_id": order.order_id, + "restaurant_name": order.restaurant_name, + "items": [ + { + "name": item.name, + "quantity": item.quantity, + "price": item.price, + "customizations": item.customizations, + } + for item in order.order_items.values() + ], + "total": order.order_total, + "status": order.order_status, + "delivery_address": order.delivery_address, + "payment_method": order.payment_method, + "created_at": order.order_date, + } + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT) + def cancel_order(self, order_id: str) -> str: + """Cancel an active order.""" + if not isinstance(order_id, str): + raise TypeError(f"Order ID must be a string, got {type(order_id)}.") + if len(order_id) == 0: + raise ValueError("Order ID must be non-empty.") + + order = self.orders.get(order_id) + if order is None: + raise KeyError(f"Order {order_id} not found") + + if order.order_status in ["delivered", "cancelled"]: + raise ValueError(f"Cannot cancel order with status: {order.order_status}") + + order.order_status = "cancelled" + return f"Order {order_id} cancelled" + + def _process_reorder_items(self, order: Order) -> tuple[list[str], list[str]]: + """Process items from an order for reordering. + + Returns: + Tuple of (added_items, unavailable_items) lists. + """ + added_items = [] + unavailable_items = [] + for item_id, cart_item in order.order_items.items(): + menu_item = self.menu_items.get(item_id) + + if menu_item is None: + unavailable_items.append(f"{cart_item.name} (no longer on menu)") + continue + + if not menu_item.available: + unavailable_items.append(f"{cart_item.name} (currently unavailable)") + continue + + self.cart[item_id] = CartItem( + item_id=cart_item.item_id, + name=cart_item.name, + quantity=cart_item.quantity, + price=menu_item.price, + customizations=cart_item.customizations, + ) + added_items.append(cart_item.name) + return added_items, unavailable_items + + @type_check + @app_tool() + @pas_event_registered(operation_type=OperationType.WRITE, event_type=EventType.AGENT) + def reorder(self, order_id: str) -> str: + """Reorder a previous order into the cart.""" + if not isinstance(order_id, str): + raise TypeError(f"Order ID must be a string, got {type(order_id)}.") + if len(order_id) == 0: + raise ValueError("Order ID must be non-empty.") + + order = self.orders.get(order_id) + if order is None: + raise KeyError(f"Order {order_id} not found") + if self.cart: + current_restaurant = self._validate_cart_restaurant_consistency() + if current_restaurant and current_restaurant != order.restaurant_id: + raise ValueError("Cart contains items from a different restaurant. Please clear your cart first.") + + added_items, unavailable_items = self._process_reorder_items(order) + + if not added_items: + if unavailable_items: + return f"Could not add any items. Unavailable: {', '.join(unavailable_items)}" + return "No items were added to cart" + + result = f"Added {len(added_items)} items to cart" + if unavailable_items: + result += f". Unavailable: {', '.join(unavailable_items)}" + + return result + + def get_state(self) -> dict[str, Any]: + """Serialize complete app state.""" + return { + "restaurants": {k: v.get_state() for k, v in self.restaurants.items()}, + "menu_items": {k: v.get_state() for k, v in self.menu_items.items()}, + "cart": { + k: { + "item_id": v.item_id, + "name": v.name, + "quantity": v.quantity, + "price": v.price, + "customizations": v.customizations, + } + for k, v in self.cart.items() + }, + "orders": {k: v.get_state() for k, v in self.orders.items()}, + "delivery_address": self.delivery_address, + "payment_method": self.payment_method, + } + + def load_state(self, state_dict: dict[str, Any]) -> None: + """Restore app state from serialized data.""" + self.restaurants.clear() + self.menu_items.clear() + self.cart.clear() + self.orders.clear() + + for r_id, r_data in state_dict.get("restaurants", {}).items(): + restaurant = Restaurant( + restaurant_id=r_data["restaurant_id"], + name=r_data["name"], + cuisine=r_data["cuisine"], + ) + restaurant.load_state(r_data) + self.restaurants[r_id] = restaurant + + for item_id, item_data in state_dict.get("menu_items", {}).items(): + item = MenuItem( + item_id=item_data["item_id"], + name=item_data["name"], + price=item_data["price"], + restaurant_id=item_data["restaurant_id"], + ) + item.load_state(item_data) + self.menu_items[item_id] = item + + for item_id, item_data in state_dict.get("cart", {}).items(): + self.cart[item_id] = CartItem(**item_data) + + for order_id, order_data in state_dict.get("orders", {}).items(): + order_date = order_data["order_date"] + if isinstance(order_date, str): + with contextlib.suppress(ValueError): + order_date = datetime.fromisoformat(order_date) + + order = Order( + order_id=order_data["order_id"], + restaurant_id=order_data["restaurant_id"], + restaurant_name=order_data["restaurant_name"], + order_status=order_data["order_status"], + order_date=order_date, + order_total=order_data["order_total"], + delivery_address=order_data["delivery_address"], + payment_method=order_data["payment_method"], + ) + order.load_state(order_data) + self.orders[order_id] = order + + self.delivery_address = state_dict.get("delivery_address", "") + self.payment_method = state_dict.get("payment_method", "") + + def handle_state_transition(self, event: CompletedEvent) -> None: + """Handle navigation state transitions based on user actions.""" + current_state = self.current_state + fname = event.function_name() + + if current_state is None or fname is None: + return + + action = event.action + event_args = action.resolved_args or action.args + metadata_value = event.metadata.return_value if event.metadata else None + + if isinstance(current_state, RestaurantList): + self._handle_restaurant_list_transition(fname, event_args, metadata_value) + elif isinstance(current_state, RestaurantDetail): + self._handle_restaurant_detail_transition(fname, event_args, metadata_value) + elif isinstance(current_state, MenuItemDetail): + self._handle_menu_item_detail_transition(fname, event_args, metadata_value) + elif isinstance(current_state, CartView): + self._handle_cart_view_transition(fname, event_args, metadata_value) + elif isinstance(current_state, CheckoutView): + self._handle_checkout_view_transition(fname, event_args, metadata_value) + elif isinstance(current_state, OrderListView): + self._handle_order_list_view_transition(fname, event_args, metadata_value) + elif isinstance(current_state, OrderDetail): + self._handle_order_detail_transition(fname, event_args, metadata_value) + + def _handle_restaurant_list_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None: + """Handle transitions from restaurant list state.""" + if fname == "open_restaurant": + restaurant_id = args.get("restaurant_id") + if restaurant_id: + self.set_current_state(RestaurantDetail(restaurant_id=str(restaurant_id))) + elif fname == "view_cart": + self.set_current_state(CartView()) + elif fname == "view_orders": + self.set_current_state(OrderListView()) + + def _handle_restaurant_detail_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None: + """Handle transitions from restaurant detail state.""" + if fname == "open_menu_item": + item_id = args.get("item_id") + if item_id and isinstance(self.current_state, RestaurantDetail): + restaurant_id = self.current_state.restaurant_id + self.set_current_state(MenuItemDetail(item_id=str(item_id), restaurant_id=restaurant_id)) + + def _handle_menu_item_detail_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None: + """Handle transitions from menu item detail state.""" + if fname == "add_cart": + self.set_current_state(CartView()) + + def _handle_cart_view_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None: + """Handle transitions from cart view state.""" + if fname == "checkout": + self.set_current_state(CheckoutView()) + + def _handle_checkout_view_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None: + """Handle transitions from checkout state.""" + if fname == "submit_order": + order_id = metadata if isinstance(metadata, str) else None + if order_id: + self.set_current_state(OrderDetail(order_id=order_id)) + + def _handle_order_list_view_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None: + """Handle transitions from order list state.""" + if fname == "open_order": + order_id = args.get("order_id") + if order_id: + self.set_current_state(OrderDetail(order_id=str(order_id))) + + def _handle_order_detail_transition(self, fname: str, args: dict[str, Any], metadata: object | None) -> None: + """Handle transitions from order detail state.""" + if fname == "reorder_order": + self.set_current_state(CartView()) diff --git a/pas/apps/doordash/states.py b/pas/apps/doordash/states.py new file mode 100644 index 0000000..906c370 --- /dev/null +++ b/pas/apps/doordash/states.py @@ -0,0 +1,492 @@ +"""State definitions for the stateful DoorDash app.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from are.simulation.types import OperationType, disable_events + +from pas.apps.core import AppState +from pas.apps.tool_decorators import pas_event_registered, user_tool + +if TYPE_CHECKING: + from pas.apps.doordash.app import StatefulDoordashApp + + +class RestaurantList(AppState): + """Home state for browsing and searching restaurants.""" + + def on_enter(self) -> None: + """Called when entering restaurant list state.""" + pass + + def on_exit(self) -> None: + """Called when leaving restaurant list state.""" + pass + + @user_tool() + @pas_event_registered() + def list_restaurants(self) -> list[dict[str, Any]]: + """List all nearby restaurants. + + Returns: + List of restaurant dictionaries with id, name, cuisine, rating, and delivery time. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).list_restaurants() + + @user_tool() + @pas_event_registered() + def search_restaurants(self, query: str) -> list[dict[str, Any]]: + """Search restaurants by keyword. + + Args: + query: Search term to match against restaurant name or cuisine type. + + Returns: + List of matching restaurant dictionaries. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).search_restaurants(query) + + @user_tool() + @pas_event_registered() + def open_restaurant(self, restaurant_id: str) -> dict[str, Any]: + """Open a restaurant detail page. + + Args: + restaurant_id: Unique identifier of the restaurant to view. + + Returns: + Restaurant details including menu items list. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).get_restaurant(restaurant_id) + + @user_tool() + @pas_event_registered() + def view_cart(self) -> dict[str, Any]: + """Navigate to cart view to see current items. + + Returns: + Current cart contents with items and total price. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).get_cart() + + @user_tool() + @pas_event_registered() + def view_orders(self) -> dict[str, Any]: + """Navigate to order history. + + Returns: + Dictionary containing orders list and pagination info. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).list_orders() + + +class RestaurantDetail(AppState): + """State for viewing a specific restaurant and its menu. + + Users can browse the restaurant's menu items, view restaurant information, + and select items to view in detail. + + Attributes: + restaurant_id: ID of the restaurant being viewed. + """ + + def __init__(self, restaurant_id: str) -> None: + """Initialize restaurant detail state. + + Args: + restaurant_id: Unique identifier of the restaurant. + """ + super().__init__() + self.restaurant_id = restaurant_id + + def on_enter(self) -> None: + """Called when entering restaurant detail state.""" + pass + + def on_exit(self) -> None: + """Called when leaving restaurant detail state.""" + pass + + @user_tool() + @pas_event_registered() + def get_restaurant(self, restaurant_id: str) -> dict[str, Any]: + """Fetch restaurant information. + + Args: + restaurant_id: Unique identifier of the restaurant. + + Returns: + Restaurant details including name, cuisine, rating, and delivery time. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).get_restaurant(restaurant_id) + + @user_tool() + @pas_event_registered() + def list_menu(self, restaurant_id: str) -> list[dict[str, Any]]: + """List menu categories and items. + + Args: + restaurant_id: Unique identifier of the restaurant. + + Returns: + List of menu items with name, price, category, and availability. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).list_menu(restaurant_id) + + @user_tool() + @pas_event_registered() + def open_menu_item(self, item_id: str) -> dict[str, Any]: + """Open detail page for a menu item. + + Args: + item_id: Unique identifier of the menu item to view. + + Returns: + Complete menu item details including description and customization options. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).get_item(item_id) + + @user_tool() + @pas_event_registered() + def search_menu_item(self, query: str) -> list[dict[str, Any]]: + """Search menu items by name or description within this restaurant. + + Args: + query: Search term to match against menu item name or description. + + Returns: + List of matching menu items with details. + """ + with disable_events(): + app = cast("StatefulDoordashApp", self.app) + return app.search_menu_item(query, restaurant_id=self.restaurant_id) + + +class MenuItemDetail(AppState): + """State for viewing a specific menu item with customization options. + + Users can view item details, select customizations, and add the item + to their cart. + + Attributes: + item_id: ID of the menu item being viewed. + restaurant_id: ID of the parent restaurant. + """ + + def __init__(self, item_id: str, restaurant_id: str) -> None: + """Initialize menu item detail state. + + Args: + item_id: Unique identifier of the menu item. + restaurant_id: Unique identifier of the parent restaurant. + """ + super().__init__() + self.item_id = item_id + self.restaurant_id = restaurant_id + + def on_enter(self) -> None: + """Called when entering menu item detail state.""" + pass + + def on_exit(self) -> None: + """Called when leaving menu item detail state.""" + pass + + @user_tool() + @pas_event_registered() + def get_item(self, item_id: str) -> dict[str, Any]: + """Fetch menu item details. + + Args: + item_id: Unique identifier of the menu item. + + Returns: + Item details including price, description, and available customizations. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).get_item(item_id) + + @user_tool() + @pas_event_registered(operation_type=OperationType.WRITE) + def add_cart(self, item_id: str, quantity: int, customizations: dict[str, str] | None = None) -> str: + """Add item to shopping cart. + + Args: + item_id: Unique identifier of the menu item. + quantity: Number of items to add. + customizations: Selected customization options (e.g., {"size": "Large", "spice": "Medium"}). + + Returns: + Success message confirming item was added to cart. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).add_to_cart(item_id, quantity, customizations) + + +class CartView(AppState): + """State for viewing and managing the shopping cart. + + Users can view all items in their cart, update quantities, remove items, + and proceed to checkout. + """ + + def on_enter(self) -> None: + """Called when entering cart view state.""" + pass + + def on_exit(self) -> None: + """Called when leaving cart view state.""" + pass + + @user_tool() + @pas_event_registered() + def get_cart(self) -> dict[str, Any]: + """Get the user's current cart contents. + + Returns: + Dictionary containing items list and total price. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).get_cart() + + @user_tool() + @pas_event_registered(operation_type=OperationType.WRITE) + def update_cart(self, item_id: str, quantity: int) -> str: + """Modify item quantity in cart. + + Args: + item_id: Unique identifier of the cart item. + quantity: New quantity (use 0 to remove item). + + Returns: + Success message confirming quantity was updated. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).update_cart(item_id, quantity) + + @user_tool() + @pas_event_registered(operation_type=OperationType.WRITE) + def remove_from_cart(self, item_id: str) -> str: + """Remove an item from cart. + + Args: + item_id: Unique identifier of the cart item to remove. + + Returns: + Success message confirming item was removed. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).remove_from_cart(item_id) + + @user_tool() + @pas_event_registered(operation_type=OperationType.WRITE) + def clear_cart(self) -> str: + """Clear all items from the cart. + + Returns: + Success message confirming cart was cleared. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).clear_cart() + + @user_tool() + @pas_event_registered() + def checkout(self) -> dict[str, Any]: + """Navigate to checkout view. + + Returns: + Checkout information including cart summary and current delivery/payment settings. + """ + with disable_events(): + app = cast("StatefulDoordashApp", self.app) + return { + "cart": app.get_cart(), + "delivery_address": app.delivery_address, + "payment_method": app.payment_method, + } + + +class CheckoutView(AppState): + """State for checkout and order placement. + + Users can set their delivery address, select payment method, and + finalize their order. + """ + + def on_enter(self) -> None: + """Called when entering checkout state.""" + pass + + def on_exit(self) -> None: + """Called when leaving checkout state.""" + pass + + @user_tool() + @pas_event_registered(operation_type=OperationType.WRITE) + def set_address(self, address: str) -> str: + """Set or update delivery address. + + Args: + address: Full delivery address string. + + Returns: + Confirmation message with the address. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).set_delivery_address(address) + + @user_tool() + @pas_event_registered(operation_type=OperationType.WRITE) + def set_payment(self, method: str) -> str: + """Set or update payment method. + + Args: + method: Payment method identifier (e.g., "credit_card", "paypal", "cash"). + + Returns: + Confirmation message with the payment method. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).set_payment_method(method) + + @user_tool() + @pas_event_registered(operation_type=OperationType.WRITE) + def submit_order(self) -> str: + """Submit order and navigate to order detail. + + Returns: + Order ID of the newly created order. + + Note: + This will empty the cart and create a new order with status "placed". + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).place_order() + + +class OrderListView(AppState): + """State for viewing order history. + + Users can view all their past orders and select one to view details. + """ + + def on_enter(self) -> None: + """Called when entering order list state.""" + pass + + def on_exit(self) -> None: + """Called when leaving order list state.""" + pass + + @user_tool() + @pas_event_registered() + def list_orders(self) -> dict[str, Any]: + """List all past orders. + + Returns: + Dictionary containing orders list and pagination info. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).list_orders() + + @user_tool() + @pas_event_registered() + def open_order(self, order_id: str) -> dict[str, Any]: + """Open detail page for a specific order. + + Args: + order_id: Unique identifier of the order to view. + + Returns: + Complete order details including all items and delivery information. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).get_order(order_id) + + +class OrderDetail(AppState): + """State for viewing a specific order's details. + + Users can view complete order information, track delivery status, + cancel active orders, or reorder previous items. + + Attributes: + order_id: ID of the order being viewed. + """ + + def __init__(self, order_id: str) -> None: + """Initialize order detail state. + + Args: + order_id: Unique identifier of the order. + """ + super().__init__() + self.order_id = order_id + + def on_enter(self) -> None: + """Called when entering order detail state.""" + pass + + def on_exit(self) -> None: + """Called when leaving order detail state.""" + pass + + @user_tool() + @pas_event_registered() + def get_order(self, order_id: str) -> dict[str, Any]: + """Fetch full order details. + + Args: + order_id: Unique identifier of the order. + + Returns: + Complete order information including items, status, delivery address, + payment method, and timestamps. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).get_order(order_id) + + @user_tool() + @pas_event_registered(operation_type=OperationType.WRITE) + def cancel_order(self, order_id: str) -> str: + """Cancel an ongoing order. + + Args: + order_id: Unique identifier of the order to cancel. + + Returns: + Confirmation message that order was cancelled. + + Note: + Only orders with status "placed", "preparing", or "delivering" can be cancelled. + Delivered or already cancelled orders cannot be cancelled. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).cancel_order(order_id) + + @user_tool() + @pas_event_registered(operation_type=OperationType.WRITE) + def reorder_order(self, order_id: str) -> str: + """Reorder all items from this order. + + Args: + order_id: Unique identifier of the order to reorder from. + + Returns: + Success message indicating items were added to cart. + + Note: + This adds all items from the specified order to the current cart. + Navigate to cart to review and checkout. + """ + with disable_events(): + return cast("StatefulDoordashApp", self.app).reorder(order_id) diff --git a/tests/apps/test_doordash_states.py b/tests/apps/test_doordash_states.py new file mode 100644 index 0000000..ae99bd2 --- /dev/null +++ b/tests/apps/test_doordash_states.py @@ -0,0 +1,517 @@ +"""Tests for the DoorDash Stateful App navigation flow. + +Key principles: +- Unit tests use _make_event + handle_state_transition for single transitions +- Integration tests use StateAwareEnvironmentWrapper for multi-step flows +- All tests verify BOTH functionality AND state transitions +""" +from __future__ import annotations + +from typing import Any + +import pytest +from are.simulation.types import Action, CompletedEvent, EventMetadata, EventType + +from pas.apps.doordash.app import StatefulDoordashApp +from pas.apps.doordash.states import ( + CartView, + CheckoutView, + MenuItemDetail, + OrderDetail, + OrderListView, + RestaurantDetail, + RestaurantList, +) +from pas.apps.proactive_aui import PASAgentUserInterface +from pas.apps.system import HomeScreenSystemApp +from pas.environment import StateAwareEnvironmentWrapper + + +def _restaurant_list_state(app: StatefulDoordashApp) -> RestaurantList: + """Get current state as RestaurantList with assertion.""" + state = app.current_state + assert isinstance(state, RestaurantList) + return state + + +def _restaurant_detail_state(app: StatefulDoordashApp) -> RestaurantDetail: + """Get current state as RestaurantDetail with assertion.""" + state = app.current_state + assert isinstance(state, RestaurantDetail) + return state + + +def _menu_item_detail_state(app: StatefulDoordashApp) -> MenuItemDetail: + """Get current state as MenuItemDetail with assertion.""" + state = app.current_state + assert isinstance(state, MenuItemDetail) + return state + + +def _cart_view_state(app: StatefulDoordashApp) -> CartView: + """Get current state as CartView with assertion.""" + state = app.current_state + assert isinstance(state, CartView) + return state + + +def _checkout_view_state(app: StatefulDoordashApp) -> CheckoutView: + """Get current state as CheckoutView with assertion.""" + state = app.current_state + assert isinstance(state, CheckoutView) + return state + + +def _order_list_view_state(app: StatefulDoordashApp) -> OrderListView: + """Get current state as OrderListView with assertion.""" + state = app.current_state + assert isinstance(state, OrderListView) + return state + + +def _order_detail_state(app: StatefulDoordashApp) -> OrderDetail: + """Get current state as OrderDetail with assertion.""" + state = app.current_state + assert isinstance(state, OrderDetail) + return state + + +def _make_event( + app: StatefulDoordashApp, + func: callable, + result: Any | None = None, + **kwargs: Any, +) -> CompletedEvent: + """Create a mock event for state transition tests.""" + action = Action( + function=func, + args={"self": app, **kwargs}, + app=app, + ) + metadata = EventMetadata() + metadata.return_value = result + return CompletedEvent( + event_id="test-event", + event_type=EventType.USER, + action=action, + metadata=metadata, + event_time=0, + ) + + +@pytest.fixture +def doordash_app() -> StatefulDoordashApp: + """Create a DoorDash app with test data.""" + app = StatefulDoordashApp(name="doordash") + + # Create test restaurants with menu items + restaurant1_id = app.create_restaurant_with_menu( + name="Pizza Place", + cuisine="Italian", + rating=4.5, + delivery_time=30, + menu_items_data=[ + {"name": "Margherita Pizza", "price": 12.99, "description": "Classic pizza", "category": "Pizza"}, + {"name": "Pepperoni Pizza", "price": 14.99, "description": "Pepperoni pizza", "category": "Pizza"}, + {"name": "Caesar Salad", "price": 8.99, "description": "Fresh salad", "category": "Salad"}, + ], + ) + + restaurant2_id = app.create_restaurant_with_menu( + name="Burger Joint", + cuisine="American", + rating=4.2, + delivery_time=25, + menu_items_data=[ + {"name": "Classic Burger", "price": 9.99, "description": "Beef burger", "category": "Burgers"}, + {"name": "Cheese Burger", "price": 10.99, "description": "Cheese burger", "category": "Burgers"}, + ], + ) + + # Store IDs for convenience + app._restaurant1_id = restaurant1_id + app._restaurant2_id = restaurant2_id + + # Get menu item IDs + restaurant1 = app.restaurants[restaurant1_id] + restaurant2 = app.restaurants[restaurant2_id] + app._item1_id = restaurant1.menu_items[0] + app._item2_id = restaurant1.menu_items[1] + app._item3_id = restaurant2.menu_items[0] + + return app + + +@pytest.fixture +def env_with_doordash() -> StateAwareEnvironmentWrapper: + """Create environment with DoorDash app registered and opened.""" + env = StateAwareEnvironmentWrapper() + system_app = HomeScreenSystemApp(name="HomeScreen") + aui_app = PASAgentUserInterface() + doordash_app = StatefulDoordashApp(name="doordash") + + # Add test data + restaurant1_id = doordash_app.create_restaurant_with_menu( + name="Pizza Place", + cuisine="Italian", + rating=4.5, + delivery_time=30, + menu_items_data=[ + {"name": "Margherita Pizza", "price": 12.99, "description": "Classic pizza", "category": "Pizza"}, + {"name": "Pepperoni Pizza", "price": 14.99, "description": "Pepperoni pizza", "category": "Pizza"}, + ], + ) + + doordash_app._restaurant1_id = restaurant1_id + restaurant1 = doordash_app.restaurants[restaurant1_id] + doordash_app._item1_id = restaurant1.menu_items[0] + doordash_app._item2_id = restaurant1.menu_items[1] + + env.register_apps([system_app, aui_app, doordash_app]) + env._open_app("doordash") + return env + + + +def test_app_starts_in_restaurant_list_state(doordash_app: StatefulDoordashApp) -> None: + """App should start in RestaurantList with empty navigation stack.""" + assert isinstance(doordash_app.current_state, RestaurantList) + assert doordash_app.navigation_stack == [] + + +def test_open_restaurant_transition(doordash_app: StatefulDoordashApp) -> None: + """Handler: open_restaurant from RestaurantList transitions to RestaurantDetail.""" + state = _restaurant_list_state(doordash_app) + restaurant_id = doordash_app._restaurant1_id + + result = state.open_restaurant(restaurant_id) + event = _make_event(doordash_app, state.open_restaurant, result=result, restaurant_id=restaurant_id) + doordash_app.handle_state_transition(event) + + assert isinstance(doordash_app.current_state, RestaurantDetail) + assert doordash_app.current_state.restaurant_id == restaurant_id + + +def test_open_menu_item_transition(doordash_app: StatefulDoordashApp) -> None: + """Handler: open_menu_item from RestaurantDetail transitions to MenuItemDetail.""" + restaurant_id = doordash_app._restaurant1_id + item_id = doordash_app._item1_id + doordash_app.set_current_state(RestaurantDetail(restaurant_id)) + + state = _restaurant_detail_state(doordash_app) + result = state.open_menu_item(item_id) + event = _make_event(doordash_app, state.open_menu_item, result=result, item_id=item_id) + doordash_app.handle_state_transition(event) + + assert isinstance(doordash_app.current_state, MenuItemDetail) + assert doordash_app.current_state.item_id == item_id + assert doordash_app.current_state.restaurant_id == restaurant_id + + +def test_add_cart_transition(doordash_app: StatefulDoordashApp) -> None: + """Handler: add_cart from MenuItemDetail transitions to CartView.""" + restaurant_id = doordash_app._restaurant1_id + item_id = doordash_app._item1_id + doordash_app.set_current_state(MenuItemDetail(item_id, restaurant_id)) + + state = _menu_item_detail_state(doordash_app) + result = state.add_cart(item_id, quantity=1) + event = _make_event(doordash_app, state.add_cart, result=result, item_id=item_id, quantity=1) + doordash_app.handle_state_transition(event) + + assert isinstance(doordash_app.current_state, CartView) + + +def test_submit_order_transition(doordash_app: StatefulDoordashApp) -> None: + """Handler: submit_order from CheckoutView transitions to OrderDetail.""" + item_id = doordash_app._item1_id + doordash_app.add_to_cart(item_id, quantity=1) + doordash_app.delivery_address = "123 Main St" + doordash_app.payment_method = "credit_card" + doordash_app.set_current_state(CheckoutView()) + + state = _checkout_view_state(doordash_app) + result = state.submit_order() + event = _make_event(doordash_app, state.submit_order, result=result) + doordash_app.handle_state_transition(event) + + assert isinstance(doordash_app.current_state, OrderDetail) + assert doordash_app.current_state.order_id == result + + + +class TestDoordashEnvironmentIntegration: + """Integration tests that exercise the full environment flow.""" + + def test_full_order_flow(self, env_with_doordash: StateAwareEnvironmentWrapper) -> None: + """Integration: RestaurantList -> RestaurantDetail -> MenuItemDetail -> CartView -> CheckoutView -> OrderDetail.""" + env = env_with_doordash + app = env.get_app_with_class(StatefulDoordashApp) + + assert isinstance(app.current_state, RestaurantList) + assert len(app.navigation_stack) == 0 + + # Navigate through the full flow + restaurant_id = app._restaurant1_id + _restaurant_list_state(app).open_restaurant(restaurant_id) + assert isinstance(app.current_state, RestaurantDetail) + assert len(app.navigation_stack) == 1 + + item_id = app._item1_id + _restaurant_detail_state(app).open_menu_item(item_id) + assert isinstance(app.current_state, MenuItemDetail) + assert len(app.navigation_stack) == 2 + + _menu_item_detail_state(app).add_cart(item_id, quantity=1) + assert isinstance(app.current_state, CartView) + assert len(app.navigation_stack) == 3 + + _cart_view_state(app).checkout() + assert isinstance(app.current_state, CheckoutView) + assert len(app.navigation_stack) == 4 + + _checkout_view_state(app).set_address("123 Main St") + _checkout_view_state(app).set_payment("credit_card") + order_id = _checkout_view_state(app).submit_order() + assert isinstance(app.current_state, OrderDetail) + assert app.current_state.order_id == order_id + assert len(app.navigation_stack) == 5 + + def test_view_orders_flow(self, env_with_doordash: StateAwareEnvironmentWrapper) -> None: + """Integration: RestaurantList -> OrderListView -> OrderDetail.""" + env = env_with_doordash + app = env.get_app_with_class(StatefulDoordashApp) + + # Create an order first + restaurant_id = app._restaurant1_id + item_id = app._item1_id + order_id = app.create_order_with_time( + restaurant_id=restaurant_id, + items=[{"item_id": item_id, "name": "Test Item", "price": 10.0, "quantity": 1}], + delivery_address="123 Main St", + payment_method="credit_card", + order_date="2024-01-01 12:00:00", + ) + + _restaurant_list_state(app).view_orders() + assert isinstance(app.current_state, OrderListView) + assert len(app.navigation_stack) == 1 + + _order_list_view_state(app).open_order(order_id) + assert isinstance(app.current_state, OrderDetail) + assert app.current_state.order_id == order_id + assert len(app.navigation_stack) == 2 + + def test_reorder_flow(self, env_with_doordash: StateAwareEnvironmentWrapper) -> None: + """Integration: OrderDetail -> reorder -> CartView -> CheckoutView.""" + env = env_with_doordash + app = env.get_app_with_class(StatefulDoordashApp) + + # Create an order first + restaurant_id = app._restaurant1_id + item_id = app._item1_id + order_id = app.create_order_with_time( + restaurant_id=restaurant_id, + items=[{"item_id": item_id, "name": "Test Item", "price": 10.0, "quantity": 1}], + delivery_address="123 Main St", + payment_method="credit_card", + order_date="2024-01-01 12:00:00", + ) + + app.set_current_state(OrderDetail(order_id)) + _order_detail_state(app).reorder_order(order_id) + assert isinstance(app.current_state, CartView) + assert len(app.cart) > 0 + + _cart_view_state(app).checkout() + assert isinstance(app.current_state, CheckoutView) + + def test_go_back_navigation(self, env_with_doordash: StateAwareEnvironmentWrapper) -> None: + """Integration: Test go_back navigation through states.""" + env = env_with_doordash + app = env.get_app_with_class(StatefulDoordashApp) + + restaurant_id = app._restaurant1_id + item_id = app._item1_id + _restaurant_list_state(app).open_restaurant(restaurant_id) + _restaurant_detail_state(app).open_menu_item(item_id) + assert isinstance(app.current_state, MenuItemDetail) + assert len(app.navigation_stack) == 2 + + app.go_back() + assert isinstance(app.current_state, RestaurantDetail) + assert len(app.navigation_stack) == 1 + + app.go_back() + assert isinstance(app.current_state, RestaurantList) + assert len(app.navigation_stack) == 0 + + def test_cart_management(self, env_with_doordash: StateAwareEnvironmentWrapper) -> None: + """Integration: Test cart operations.""" + env = env_with_doordash + app = env.get_app_with_class(StatefulDoordashApp) + + item_id = app._item1_id + app.add_to_cart(item_id, quantity=2) + + _restaurant_list_state(app).view_cart() + assert isinstance(app.current_state, CartView) + + cart_state = _cart_view_state(app) + cart = cart_state.get_cart() + assert len(cart["items"]) == 1 + assert cart["items"][0]["quantity"] == 2 + + cart_state.update_cart(item_id, quantity=3) + cart = cart_state.get_cart() + assert cart["items"][0]["quantity"] == 3 + + cart_state.remove_from_cart(item_id) + cart = cart_state.get_cart() + assert len(cart["items"]) == 0 + assert cart["total"] == 0 + + def test_multiple_items_cart_flow(self, env_with_doordash: StateAwareEnvironmentWrapper) -> None: + """Integration: Add multiple items to cart, update quantities, then checkout.""" + env = env_with_doordash + app = env.get_app_with_class(StatefulDoordashApp) + + restaurant_id = app._restaurant1_id + item1_id = app._item1_id + item2_id = app._item2_id + + _restaurant_list_state(app).open_restaurant(restaurant_id) + _restaurant_detail_state(app).open_menu_item(item1_id) + _menu_item_detail_state(app).add_cart(item1_id, quantity=2) + assert isinstance(app.current_state, CartView) + + app.go_back() + app.go_back() + _restaurant_detail_state(app).open_menu_item(item2_id) + _menu_item_detail_state(app).add_cart(item2_id, quantity=1) + assert isinstance(app.current_state, CartView) + + cart = _cart_view_state(app).get_cart() + assert len(cart["items"]) == 2 + + _cart_view_state(app).update_cart(item1_id, quantity=3) + _cart_view_state(app).remove_from_cart(item2_id) + cart = _cart_view_state(app).get_cart() + assert len(cart["items"]) == 1 + + _cart_view_state(app).checkout() + assert isinstance(app.current_state, CheckoutView) + + _checkout_view_state(app).set_address("789 Pine St") + _checkout_view_state(app).set_payment("debit_card") + order_id = _checkout_view_state(app).submit_order() + assert isinstance(app.current_state, OrderDetail) + assert len(app.cart) == 0 + + + +def test_add_item_from_different_restaurant_error(doordash_app: StatefulDoordashApp) -> None: + """Test that adding items from different restaurants to cart raises error.""" + item1_id = doordash_app._item1_id # From restaurant 1 + item3_id = doordash_app._item3_id # From restaurant 2 + + doordash_app.add_to_cart(item1_id, quantity=1) + + with pytest.raises(ValueError, match="different restaurant"): + doordash_app.add_to_cart(item3_id, quantity=1) + + +def test_place_order_with_empty_cart_error(doordash_app: StatefulDoordashApp) -> None: + """Test that placing order with empty cart raises error.""" + doordash_app.delivery_address = "123 Main St" + doordash_app.payment_method = "credit_card" + + with pytest.raises(ValueError, match="Cart is empty"): + doordash_app.place_order() + + +def test_place_order_without_address_error(doordash_app: StatefulDoordashApp) -> None: + """Test that placing order without address raises error.""" + item_id = doordash_app._item1_id + doordash_app.add_to_cart(item_id, quantity=1) + doordash_app.payment_method = "credit_card" + + with pytest.raises(ValueError, match="Delivery address not set"): + doordash_app.place_order() + + +def test_place_order_without_payment_error(doordash_app: StatefulDoordashApp) -> None: + """Test that placing order without payment method raises error.""" + item_id = doordash_app._item1_id + doordash_app.add_to_cart(item_id, quantity=1) + doordash_app.delivery_address = "123 Main St" + + with pytest.raises(ValueError, match="Payment method not set"): + doordash_app.place_order() + + +def test_cancel_delivered_order_error(doordash_app: StatefulDoordashApp) -> None: + """Test that canceling a delivered order raises error.""" + restaurant_id = doordash_app._restaurant1_id + item_id = doordash_app._item1_id + order_id = doordash_app.create_order_with_time( + restaurant_id=restaurant_id, + items=[{"item_id": item_id, "name": "Test Item", "price": 10.0, "quantity": 1}], + delivery_address="123 Main St", + payment_method="credit_card", + order_date="2024-01-01 12:00:00", + order_status="delivered", + ) + + with pytest.raises(ValueError, match="Cannot cancel order with status"): + doordash_app.cancel_order(order_id) + + +def test_save_and_load_state(doordash_app: StatefulDoordashApp) -> None: + """Test that app state can be saved and loaded correctly.""" + item_id = doordash_app._item1_id + doordash_app.add_to_cart(item_id, quantity=2) + doordash_app.delivery_address = "123 Main St" + doordash_app.payment_method = "credit_card" + + restaurant_id = doordash_app._restaurant1_id + order_id = doordash_app.create_order_with_time( + restaurant_id=restaurant_id, + items=[{"item_id": item_id, "name": "Test Item", "price": 10.0, "quantity": 1}], + delivery_address="456 Oak Ave", + payment_method="paypal", + order_date="2024-01-01 12:00:00", + ) + + state_dict = doordash_app.get_state() + + new_app = StatefulDoordashApp(name="doordash2") + new_app.load_state(state_dict) + + assert len(new_app.restaurants) == len(doordash_app.restaurants) + assert len(new_app.menu_items) == len(doordash_app.menu_items) + assert len(new_app.cart) == len(doordash_app.cart) + assert len(new_app.orders) == len(doordash_app.orders) + assert new_app.delivery_address == doordash_app.delivery_address + assert new_app.payment_method == doordash_app.payment_method + assert new_app.cart[item_id].quantity == doordash_app.cart[item_id].quantity + assert new_app.orders[order_id].order_status == doordash_app.orders[order_id].order_status + + +def test_reset_app(doordash_app: StatefulDoordashApp) -> None: + """Test that reset clears all app state.""" + item_id = doordash_app._item1_id + doordash_app.add_to_cart(item_id, quantity=1) + doordash_app.delivery_address = "123 Main St" + doordash_app.payment_method = "credit_card" + + doordash_app.reset() + + assert len(doordash_app.restaurants) == 0 + assert len(doordash_app.menu_items) == 0 + assert len(doordash_app.cart) == 0 + assert len(doordash_app.orders) == 0 + assert doordash_app.delivery_address == "" + assert doordash_app.payment_method == "" + assert isinstance(doordash_app.current_state, RestaurantList) + assert len(doordash_app.navigation_stack) == 0 From f49b62ee9804f50e493416b2a2adb8bf86e117c3 Mon Sep 17 00:00:00 2001 From: Chang Huan Date: Mon, 12 Jan 2026 15:11:54 -0800 Subject: [PATCH 2/2] Renamed to food_delivery app --- docs/apps/{doordash.md => food_delivery.md} | 52 ++-- docs/apps/index.md | 2 +- pas/apps/__init__.py | 6 +- .../{doordash => food_delivery}/__init__.py | 8 +- pas/apps/{doordash => food_delivery}/app.py | 12 +- .../{doordash => food_delivery}/states.py | 52 ++-- ...states.py => test_food_delivery_states.py} | 278 +++++++++--------- 7 files changed, 205 insertions(+), 205 deletions(-) rename docs/apps/{doordash.md => food_delivery.md} (50%) rename pas/apps/{doordash => food_delivery}/__init__.py (60%) rename pas/apps/{doordash => food_delivery}/app.py (96%) rename pas/apps/{doordash => food_delivery}/states.py (83%) rename tests/apps/{test_doordash_states.py => test_food_delivery_states.py} (58%) diff --git a/docs/apps/doordash.md b/docs/apps/food_delivery.md similarity index 50% rename from docs/apps/doordash.md rename to docs/apps/food_delivery.md index 70f8474..361f27b 100644 --- a/docs/apps/doordash.md +++ b/docs/apps/food_delivery.md @@ -1,6 +1,6 @@ -# Stateful DoorDash App +# Stateful Food Delivery App -`pas.apps.doordash.app.StatefulDoordashApp` is a stateful DoorDash application that manages restaurant browsing, cart management, checkout, and order history with PAS navigation. +`pas.apps.food_delivery.app.StatefulFoodDeliveryApp` is a stateful Food Delivery application that manages restaurant browsing, cart management, checkout, and order history with PAS navigation. It launches in `RestaurantList` and transitions between restaurant browsing, menu viewing, cart management, checkout, and order tracking flows based on completed operations. --- @@ -15,11 +15,11 @@ Home screen for browsing and searching restaurants, viewing cart, and accessing | Tool | Backend call(s) | Returns | Navigation effect | | --- | --- | --- | --- | -| `list_restaurants()` | `StatefulDoordashApp.list_restaurants()` | `list[dict[str, Any]]` restaurant summaries | Remains in `RestaurantList` | -| `search_restaurants(query: str)` | `StatefulDoordashApp.search_restaurants(query)` | `list[dict[str, Any]]` matching restaurants | Remains in `RestaurantList` | -| `open_restaurant(restaurant_id: str)` | `StatefulDoordashApp.get_restaurant(restaurant_id)` | `dict[str, Any]` restaurant details | → `RestaurantDetail(restaurant_id)` | -| `view_cart()` | `StatefulDoordashApp.get_cart()` | `dict[str, Any]` cart contents | → `CartView` | -| `view_orders()` | `StatefulDoordashApp.list_orders()` | `dict[str, Any]` orders list with pagination | → `OrderListView` | +| `list_restaurants()` | `StatefulFoodDeliveryApp.list_restaurants()` | `list[dict[str, Any]]` restaurant summaries | Remains in `RestaurantList` | +| `search_restaurants(query: str)` | `StatefulFoodDeliveryApp.search_restaurants(query)` | `list[dict[str, Any]]` matching restaurants | Remains in `RestaurantList` | +| `open_restaurant(restaurant_id: str)` | `StatefulFoodDeliveryApp.get_restaurant(restaurant_id)` | `dict[str, Any]` restaurant details | → `RestaurantDetail(restaurant_id)` | +| `view_cart()` | `StatefulFoodDeliveryApp.get_cart()` | `dict[str, Any]` cart contents | → `CartView` | +| `view_orders()` | `StatefulFoodDeliveryApp.list_orders()` | `dict[str, Any]` orders list with pagination | → `OrderListView` | --- @@ -29,10 +29,10 @@ Screen for viewing a specific restaurant and its menu items. | Tool | Backend call(s) | Returns | Navigation effect | | --- | --- | --- | --- | -| `get_restaurant(restaurant_id: str)` | `StatefulDoordashApp.get_restaurant(restaurant_id)` | `dict[str, Any]` restaurant details | Remains in `RestaurantDetail` | -| `list_menu(restaurant_id: str)` | `StatefulDoordashApp.list_menu(restaurant_id)` | `list[dict[str, Any]]` menu items | Remains in `RestaurantDetail` | -| `search_menu_item(query: str)` | `StatefulDoordashApp.search_menu_item(query, restaurant_id)` | `list[dict[str, Any]]` matching menu items | Remains in `RestaurantDetail` | -| `open_menu_item(item_id: str)` | `StatefulDoordashApp.get_item(item_id)` | `dict[str, Any]` menu item details | → `MenuItemDetail(item_id, restaurant_id)` | +| `get_restaurant(restaurant_id: str)` | `StatefulFoodDeliveryApp.get_restaurant(restaurant_id)` | `dict[str, Any]` restaurant details | Remains in `RestaurantDetail` | +| `list_menu(restaurant_id: str)` | `StatefulFoodDeliveryApp.list_menu(restaurant_id)` | `list[dict[str, Any]]` menu items | Remains in `RestaurantDetail` | +| `search_menu_item(query: str)` | `StatefulFoodDeliveryApp.search_menu_item(query, restaurant_id)` | `list[dict[str, Any]]` matching menu items | Remains in `RestaurantDetail` | +| `open_menu_item(item_id: str)` | `StatefulFoodDeliveryApp.get_item(item_id)` | `dict[str, Any]` menu item details | → `MenuItemDetail(item_id, restaurant_id)` | | `go_back()` | — | Navigation indicator string | → Previous state (via navigation stack) | --- @@ -43,8 +43,8 @@ Screen for viewing a specific menu item with customization options. | Tool | Backend call(s) | Returns | Navigation effect | | --- | --- | --- | --- | -| `get_item(item_id: str)` | `StatefulDoordashApp.get_item(item_id)` | `dict[str, Any]` menu item details | Remains in `MenuItemDetail` | -| `add_cart(item_id: str, quantity: int, customizations: dict[str, str] \| None = None)` | `StatefulDoordashApp.add_to_cart(item_id, quantity, customizations)` | `str` confirmation message | → `CartView` | +| `get_item(item_id: str)` | `StatefulFoodDeliveryApp.get_item(item_id)` | `dict[str, Any]` menu item details | Remains in `MenuItemDetail` | +| `add_cart(item_id: str, quantity: int, customizations: dict[str, str] \| None = None)` | `StatefulFoodDeliveryApp.add_to_cart(item_id, quantity, customizations)` | `str` confirmation message | → `CartView` | | `go_back()` | — | Navigation indicator string | → Previous state (via navigation stack) | --- @@ -55,10 +55,10 @@ Screen for viewing and managing the shopping cart. | Tool | Backend call(s) | Returns | Navigation effect | | --- | --- | --- | --- | -| `get_cart()` | `StatefulDoordashApp.get_cart()` | `dict[str, Any]` cart contents with items and total | Remains in `CartView` | -| `update_cart(item_id: str, quantity: int)` | `StatefulDoordashApp.update_cart(item_id, quantity)` | `str` confirmation message | Remains in `CartView` | -| `remove_from_cart(item_id: str)` | `StatefulDoordashApp.remove_from_cart(item_id)` | `str` confirmation message | Remains in `CartView` | -| `clear_cart()` | `StatefulDoordashApp.clear_cart()` | `str` confirmation message | Remains in `CartView` | +| `get_cart()` | `StatefulFoodDeliveryApp.get_cart()` | `dict[str, Any]` cart contents with items and total | Remains in `CartView` | +| `update_cart(item_id: str, quantity: int)` | `StatefulFoodDeliveryApp.update_cart(item_id, quantity)` | `str` confirmation message | Remains in `CartView` | +| `remove_from_cart(item_id: str)` | `StatefulFoodDeliveryApp.remove_from_cart(item_id)` | `str` confirmation message | Remains in `CartView` | +| `clear_cart()` | `StatefulFoodDeliveryApp.clear_cart()` | `str` confirmation message | Remains in `CartView` | | `checkout()` | Reads cart, delivery address, and payment method | `dict[str, Any]` checkout info | → `CheckoutView` | | `go_back()` | — | Navigation indicator string | → Previous state (via navigation stack) | @@ -70,9 +70,9 @@ Screen for checkout and order placement. | Tool | Backend call(s) | Returns | Navigation effect | | --- | --- | --- | --- | -| `set_address(address: str)` | `StatefulDoordashApp.set_delivery_address(address)` | `str` confirmation message | Remains in `CheckoutView` | -| `set_payment(method: str)` | `StatefulDoordashApp.set_payment_method(method)` | `str` confirmation message | Remains in `CheckoutView` | -| `submit_order()` | `StatefulDoordashApp.place_order()` | `str` order ID | → `OrderDetail(order_id)` | +| `set_address(address: str)` | `StatefulFoodDeliveryApp.set_delivery_address(address)` | `str` confirmation message | Remains in `CheckoutView` | +| `set_payment(method: str)` | `StatefulFoodDeliveryApp.set_payment_method(method)` | `str` confirmation message | Remains in `CheckoutView` | +| `submit_order()` | `StatefulFoodDeliveryApp.place_order()` | `str` order ID | → `OrderDetail(order_id)` | | `go_back()` | — | Navigation indicator string | → Previous state (via navigation stack) | --- @@ -83,8 +83,8 @@ Screen for viewing order history. | Tool | Backend call(s) | Returns | Navigation effect | | --- | --- | --- | --- | -| `list_orders()` | `StatefulDoordashApp.list_orders()` | `dict[str, Any]` orders list with pagination | Remains in `OrderListView` | -| `open_order(order_id: str)` | `StatefulDoordashApp.get_order(order_id)` | `dict[str, Any]` order details | → `OrderDetail(order_id)` | +| `list_orders()` | `StatefulFoodDeliveryApp.list_orders()` | `dict[str, Any]` orders list with pagination | Remains in `OrderListView` | +| `open_order(order_id: str)` | `StatefulFoodDeliveryApp.get_order(order_id)` | `dict[str, Any]` order details | → `OrderDetail(order_id)` | | `go_back()` | — | Navigation indicator string | → Previous state (via navigation stack) | --- @@ -95,9 +95,9 @@ Screen for viewing a specific order's details. | Tool | Backend call(s) | Returns | Navigation effect | | --- | --- | --- | --- | -| `get_order(order_id: str)` | `StatefulDoordashApp.get_order(order_id)` | `dict[str, Any]` complete order details | Remains in `OrderDetail` | -| `cancel_order(order_id: str)` | `StatefulDoordashApp.cancel_order(order_id)` | `str` confirmation message | Remains in `OrderDetail` | -| `reorder_order(order_id: str)` | `StatefulDoordashApp.reorder(order_id)` | `str` confirmation message | → `CartView` | +| `get_order(order_id: str)` | `StatefulFoodDeliveryApp.get_order(order_id)` | `dict[str, Any]` complete order details | Remains in `OrderDetail` | +| `cancel_order(order_id: str)` | `StatefulFoodDeliveryApp.cancel_order(order_id)` | `str` confirmation message | Remains in `OrderDetail` | +| `reorder_order(order_id: str)` | `StatefulFoodDeliveryApp.reorder(order_id)` | `str` confirmation message | → `CartView` | | `go_back()` | — | Navigation indicator string | → Previous state (via navigation stack) | --- @@ -118,7 +118,7 @@ Screen for viewing a specific order's details. ## Navigation Helpers -- Navigation transitions are handled in `StatefulDoordashApp.handle_state_transition` based on the completed tool name. +- Navigation transitions are handled in `StatefulFoodDeliveryApp.handle_state_transition` based on the completed tool name. - States store context parameters (e.g., `restaurant_id`, `item_id`, `order_id`) for navigation. - Cart can only contain items from a single restaurant at a time. - After order submission, the cart is automatically cleared. diff --git a/docs/apps/index.md b/docs/apps/index.md index 37817bc..474861e 100644 --- a/docs/apps/index.md +++ b/docs/apps/index.md @@ -13,7 +13,7 @@ Use these pages to explore the current user-facing tool surface for each PAS sta - [Stateful Reminder App](./reminder.md) - [Stateful Shopping App](./shopping.md) - [Stateful Note App](./notes.md) -- [Stateful DoorDash App](./doordash.md) +- [Stateful Food Delivery App](./food_delivery.md) ## Navigation Framework Recap diff --git a/pas/apps/__init__.py b/pas/apps/__init__.py index a549988..75fb925 100644 --- a/pas/apps/__init__.py +++ b/pas/apps/__init__.py @@ -9,8 +9,8 @@ from pas.apps.calendar.app import StatefulCalendarApp from pas.apps.contacts.app import StatefulContactsApp from pas.apps.core import AppState, StatefulApp -from pas.apps.doordash import StatefulDoordashApp from pas.apps.email.app import StatefulEmailApp +from pas.apps.food_delivery import StatefulFoodDeliveryApp from pas.apps.messaging.app import StatefulMessagingApp from pas.apps.note import StatefulNotesApp from pas.apps.proactive_aui import PASAgentUserInterface @@ -29,8 +29,8 @@ "StatefulCabApp", "StatefulCalendarApp", "StatefulContactsApp", - "StatefulDoordashApp", "StatefulEmailApp", + "StatefulFoodDeliveryApp", "StatefulMessagingApp", "StatefulNotesApp", "StatefulReminderApp", @@ -50,5 +50,5 @@ StatefulMessagingApp, StatefulNotesApp, StatefulReminderApp, - StatefulDoordashApp, + StatefulFoodDeliveryApp, ] diff --git a/pas/apps/doordash/__init__.py b/pas/apps/food_delivery/__init__.py similarity index 60% rename from pas/apps/doordash/__init__.py rename to pas/apps/food_delivery/__init__.py index 4109cc7..5e7059b 100644 --- a/pas/apps/doordash/__init__.py +++ b/pas/apps/food_delivery/__init__.py @@ -1,9 +1,9 @@ -"""Stateful doordash application package.""" +"""Stateful food delivery application package.""" from __future__ import annotations -from pas.apps.doordash.app import StatefulDoordashApp -from pas.apps.doordash.states import ( +from pas.apps.food_delivery.app import StatefulFoodDeliveryApp +from pas.apps.food_delivery.states import ( CartView, CheckoutView, MenuItemDetail, @@ -21,5 +21,5 @@ "OrderListView", "RestaurantDetail", "RestaurantList", - "StatefulDoordashApp", + "StatefulFoodDeliveryApp", ] diff --git a/pas/apps/doordash/app.py b/pas/apps/food_delivery/app.py similarity index 96% rename from pas/apps/doordash/app.py rename to pas/apps/food_delivery/app.py index 90e8040..e9ffc1f 100644 --- a/pas/apps/doordash/app.py +++ b/pas/apps/food_delivery/app.py @@ -1,4 +1,4 @@ -"""Stateful DoorDash app with PAS navigation.""" +"""Stateful Food Delivery app with PAS navigation.""" from __future__ import annotations @@ -19,7 +19,7 @@ from are.simulation.types import CompletedEvent from pas.apps.core import StatefulApp -from pas.apps.doordash.states import ( +from pas.apps.food_delivery.states import ( CartView, CheckoutView, MenuItemDetail, @@ -214,8 +214,8 @@ def load_state(self, state_dict: dict[str, Any]) -> None: @dataclass -class StatefulDoordashApp(StatefulApp): - """A DoorDash application that manages restaurant browsing, cart management, checkout, and order history with state-aware transitions. +class StatefulFoodDeliveryApp(StatefulApp): + """A Food Delivery application that manages restaurant browsing, cart management, checkout, and order history with state-aware transitions. Key Features: - Restaurant Management: Browse, search, and view restaurant details @@ -240,8 +240,8 @@ class StatefulDoordashApp(StatefulApp): payment_method: str = "" def __post_init__(self) -> None: - """Initialize the DoorDash app.""" - super().__init__(self.name or "doordash") + """Initialize the Food Delivery app.""" + super().__init__(self.name or "food_delivery") self.load_root_state() def create_root_state(self) -> RestaurantList: diff --git a/pas/apps/doordash/states.py b/pas/apps/food_delivery/states.py similarity index 83% rename from pas/apps/doordash/states.py rename to pas/apps/food_delivery/states.py index 906c370..98e4a3a 100644 --- a/pas/apps/doordash/states.py +++ b/pas/apps/food_delivery/states.py @@ -1,4 +1,4 @@ -"""State definitions for the stateful DoorDash app.""" +"""State definitions for the stateful Food Delivery app.""" from __future__ import annotations @@ -10,7 +10,7 @@ from pas.apps.tool_decorators import pas_event_registered, user_tool if TYPE_CHECKING: - from pas.apps.doordash.app import StatefulDoordashApp + from pas.apps.food_delivery.app import StatefulFoodDeliveryApp class RestaurantList(AppState): @@ -33,7 +33,7 @@ def list_restaurants(self) -> list[dict[str, Any]]: List of restaurant dictionaries with id, name, cuisine, rating, and delivery time. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).list_restaurants() + return cast("StatefulFoodDeliveryApp", self.app).list_restaurants() @user_tool() @pas_event_registered() @@ -47,7 +47,7 @@ def search_restaurants(self, query: str) -> list[dict[str, Any]]: List of matching restaurant dictionaries. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).search_restaurants(query) + return cast("StatefulFoodDeliveryApp", self.app).search_restaurants(query) @user_tool() @pas_event_registered() @@ -61,7 +61,7 @@ def open_restaurant(self, restaurant_id: str) -> dict[str, Any]: Restaurant details including menu items list. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).get_restaurant(restaurant_id) + return cast("StatefulFoodDeliveryApp", self.app).get_restaurant(restaurant_id) @user_tool() @pas_event_registered() @@ -72,7 +72,7 @@ def view_cart(self) -> dict[str, Any]: Current cart contents with items and total price. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).get_cart() + return cast("StatefulFoodDeliveryApp", self.app).get_cart() @user_tool() @pas_event_registered() @@ -83,7 +83,7 @@ def view_orders(self) -> dict[str, Any]: Dictionary containing orders list and pagination info. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).list_orders() + return cast("StatefulFoodDeliveryApp", self.app).list_orders() class RestaurantDetail(AppState): @@ -125,7 +125,7 @@ def get_restaurant(self, restaurant_id: str) -> dict[str, Any]: Restaurant details including name, cuisine, rating, and delivery time. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).get_restaurant(restaurant_id) + return cast("StatefulFoodDeliveryApp", self.app).get_restaurant(restaurant_id) @user_tool() @pas_event_registered() @@ -139,7 +139,7 @@ def list_menu(self, restaurant_id: str) -> list[dict[str, Any]]: List of menu items with name, price, category, and availability. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).list_menu(restaurant_id) + return cast("StatefulFoodDeliveryApp", self.app).list_menu(restaurant_id) @user_tool() @pas_event_registered() @@ -153,7 +153,7 @@ def open_menu_item(self, item_id: str) -> dict[str, Any]: Complete menu item details including description and customization options. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).get_item(item_id) + return cast("StatefulFoodDeliveryApp", self.app).get_item(item_id) @user_tool() @pas_event_registered() @@ -167,7 +167,7 @@ def search_menu_item(self, query: str) -> list[dict[str, Any]]: List of matching menu items with details. """ with disable_events(): - app = cast("StatefulDoordashApp", self.app) + app = cast("StatefulFoodDeliveryApp", self.app) return app.search_menu_item(query, restaurant_id=self.restaurant_id) @@ -213,7 +213,7 @@ def get_item(self, item_id: str) -> dict[str, Any]: Item details including price, description, and available customizations. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).get_item(item_id) + return cast("StatefulFoodDeliveryApp", self.app).get_item(item_id) @user_tool() @pas_event_registered(operation_type=OperationType.WRITE) @@ -229,7 +229,7 @@ def add_cart(self, item_id: str, quantity: int, customizations: dict[str, str] | Success message confirming item was added to cart. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).add_to_cart(item_id, quantity, customizations) + return cast("StatefulFoodDeliveryApp", self.app).add_to_cart(item_id, quantity, customizations) class CartView(AppState): @@ -256,7 +256,7 @@ def get_cart(self) -> dict[str, Any]: Dictionary containing items list and total price. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).get_cart() + return cast("StatefulFoodDeliveryApp", self.app).get_cart() @user_tool() @pas_event_registered(operation_type=OperationType.WRITE) @@ -271,7 +271,7 @@ def update_cart(self, item_id: str, quantity: int) -> str: Success message confirming quantity was updated. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).update_cart(item_id, quantity) + return cast("StatefulFoodDeliveryApp", self.app).update_cart(item_id, quantity) @user_tool() @pas_event_registered(operation_type=OperationType.WRITE) @@ -285,7 +285,7 @@ def remove_from_cart(self, item_id: str) -> str: Success message confirming item was removed. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).remove_from_cart(item_id) + return cast("StatefulFoodDeliveryApp", self.app).remove_from_cart(item_id) @user_tool() @pas_event_registered(operation_type=OperationType.WRITE) @@ -296,7 +296,7 @@ def clear_cart(self) -> str: Success message confirming cart was cleared. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).clear_cart() + return cast("StatefulFoodDeliveryApp", self.app).clear_cart() @user_tool() @pas_event_registered() @@ -307,7 +307,7 @@ def checkout(self) -> dict[str, Any]: Checkout information including cart summary and current delivery/payment settings. """ with disable_events(): - app = cast("StatefulDoordashApp", self.app) + app = cast("StatefulFoodDeliveryApp", self.app) return { "cart": app.get_cart(), "delivery_address": app.delivery_address, @@ -342,7 +342,7 @@ def set_address(self, address: str) -> str: Confirmation message with the address. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).set_delivery_address(address) + return cast("StatefulFoodDeliveryApp", self.app).set_delivery_address(address) @user_tool() @pas_event_registered(operation_type=OperationType.WRITE) @@ -356,7 +356,7 @@ def set_payment(self, method: str) -> str: Confirmation message with the payment method. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).set_payment_method(method) + return cast("StatefulFoodDeliveryApp", self.app).set_payment_method(method) @user_tool() @pas_event_registered(operation_type=OperationType.WRITE) @@ -370,7 +370,7 @@ def submit_order(self) -> str: This will empty the cart and create a new order with status "placed". """ with disable_events(): - return cast("StatefulDoordashApp", self.app).place_order() + return cast("StatefulFoodDeliveryApp", self.app).place_order() class OrderListView(AppState): @@ -396,7 +396,7 @@ def list_orders(self) -> dict[str, Any]: Dictionary containing orders list and pagination info. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).list_orders() + return cast("StatefulFoodDeliveryApp", self.app).list_orders() @user_tool() @pas_event_registered() @@ -410,7 +410,7 @@ def open_order(self, order_id: str) -> dict[str, Any]: Complete order details including all items and delivery information. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).get_order(order_id) + return cast("StatefulFoodDeliveryApp", self.app).get_order(order_id) class OrderDetail(AppState): @@ -453,7 +453,7 @@ def get_order(self, order_id: str) -> dict[str, Any]: payment method, and timestamps. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).get_order(order_id) + return cast("StatefulFoodDeliveryApp", self.app).get_order(order_id) @user_tool() @pas_event_registered(operation_type=OperationType.WRITE) @@ -471,7 +471,7 @@ def cancel_order(self, order_id: str) -> str: Delivered or already cancelled orders cannot be cancelled. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).cancel_order(order_id) + return cast("StatefulFoodDeliveryApp", self.app).cancel_order(order_id) @user_tool() @pas_event_registered(operation_type=OperationType.WRITE) @@ -489,4 +489,4 @@ def reorder_order(self, order_id: str) -> str: Navigate to cart to review and checkout. """ with disable_events(): - return cast("StatefulDoordashApp", self.app).reorder(order_id) + return cast("StatefulFoodDeliveryApp", self.app).reorder(order_id) diff --git a/tests/apps/test_doordash_states.py b/tests/apps/test_food_delivery_states.py similarity index 58% rename from tests/apps/test_doordash_states.py rename to tests/apps/test_food_delivery_states.py index ae99bd2..b75074d 100644 --- a/tests/apps/test_doordash_states.py +++ b/tests/apps/test_food_delivery_states.py @@ -1,4 +1,4 @@ -"""Tests for the DoorDash Stateful App navigation flow. +"""Tests for the Food Delivery Stateful App navigation flow. Key principles: - Unit tests use _make_event + handle_state_transition for single transitions @@ -12,8 +12,8 @@ import pytest from are.simulation.types import Action, CompletedEvent, EventMetadata, EventType -from pas.apps.doordash.app import StatefulDoordashApp -from pas.apps.doordash.states import ( +from pas.apps.food_delivery.app import StatefulFoodDeliveryApp +from pas.apps.food_delivery.states import ( CartView, CheckoutView, MenuItemDetail, @@ -27,49 +27,49 @@ from pas.environment import StateAwareEnvironmentWrapper -def _restaurant_list_state(app: StatefulDoordashApp) -> RestaurantList: +def _restaurant_list_state(app: StatefulFoodDeliveryApp) -> RestaurantList: """Get current state as RestaurantList with assertion.""" state = app.current_state assert isinstance(state, RestaurantList) return state -def _restaurant_detail_state(app: StatefulDoordashApp) -> RestaurantDetail: +def _restaurant_detail_state(app: StatefulFoodDeliveryApp) -> RestaurantDetail: """Get current state as RestaurantDetail with assertion.""" state = app.current_state assert isinstance(state, RestaurantDetail) return state -def _menu_item_detail_state(app: StatefulDoordashApp) -> MenuItemDetail: +def _menu_item_detail_state(app: StatefulFoodDeliveryApp) -> MenuItemDetail: """Get current state as MenuItemDetail with assertion.""" state = app.current_state assert isinstance(state, MenuItemDetail) return state -def _cart_view_state(app: StatefulDoordashApp) -> CartView: +def _cart_view_state(app: StatefulFoodDeliveryApp) -> CartView: """Get current state as CartView with assertion.""" state = app.current_state assert isinstance(state, CartView) return state -def _checkout_view_state(app: StatefulDoordashApp) -> CheckoutView: +def _checkout_view_state(app: StatefulFoodDeliveryApp) -> CheckoutView: """Get current state as CheckoutView with assertion.""" state = app.current_state assert isinstance(state, CheckoutView) return state -def _order_list_view_state(app: StatefulDoordashApp) -> OrderListView: +def _order_list_view_state(app: StatefulFoodDeliveryApp) -> OrderListView: """Get current state as OrderListView with assertion.""" state = app.current_state assert isinstance(state, OrderListView) return state -def _order_detail_state(app: StatefulDoordashApp) -> OrderDetail: +def _order_detail_state(app: StatefulFoodDeliveryApp) -> OrderDetail: """Get current state as OrderDetail with assertion.""" state = app.current_state assert isinstance(state, OrderDetail) @@ -77,7 +77,7 @@ def _order_detail_state(app: StatefulDoordashApp) -> OrderDetail: def _make_event( - app: StatefulDoordashApp, + app: StatefulFoodDeliveryApp, func: callable, result: Any | None = None, **kwargs: Any, @@ -100,9 +100,9 @@ def _make_event( @pytest.fixture -def doordash_app() -> StatefulDoordashApp: - """Create a DoorDash app with test data.""" - app = StatefulDoordashApp(name="doordash") +def food_delivery_app() -> StatefulFoodDeliveryApp: + """Create a Food Delivery app with test data.""" + app = StatefulFoodDeliveryApp(name="food_delivery") # Create test restaurants with menu items restaurant1_id = app.create_restaurant_with_menu( @@ -143,15 +143,15 @@ def doordash_app() -> StatefulDoordashApp: @pytest.fixture -def env_with_doordash() -> StateAwareEnvironmentWrapper: - """Create environment with DoorDash app registered and opened.""" +def env_with_food_delivery() -> StateAwareEnvironmentWrapper: + """Create environment with Food Delivery app registered and opened.""" env = StateAwareEnvironmentWrapper() system_app = HomeScreenSystemApp(name="HomeScreen") aui_app = PASAgentUserInterface() - doordash_app = StatefulDoordashApp(name="doordash") + food_delivery_app = StatefulFoodDeliveryApp(name="food_delivery") # Add test data - restaurant1_id = doordash_app.create_restaurant_with_menu( + restaurant1_id = food_delivery_app.create_restaurant_with_menu( name="Pizza Place", cuisine="Italian", rating=4.5, @@ -162,91 +162,91 @@ def env_with_doordash() -> StateAwareEnvironmentWrapper: ], ) - doordash_app._restaurant1_id = restaurant1_id - restaurant1 = doordash_app.restaurants[restaurant1_id] - doordash_app._item1_id = restaurant1.menu_items[0] - doordash_app._item2_id = restaurant1.menu_items[1] + food_delivery_app._restaurant1_id = restaurant1_id + restaurant1 = food_delivery_app.restaurants[restaurant1_id] + food_delivery_app._item1_id = restaurant1.menu_items[0] + food_delivery_app._item2_id = restaurant1.menu_items[1] - env.register_apps([system_app, aui_app, doordash_app]) - env._open_app("doordash") + env.register_apps([system_app, aui_app, food_delivery_app]) + env._open_app("food_delivery") return env -def test_app_starts_in_restaurant_list_state(doordash_app: StatefulDoordashApp) -> None: +def test_app_starts_in_restaurant_list_state(food_delivery_app: StatefulFoodDeliveryApp) -> None: """App should start in RestaurantList with empty navigation stack.""" - assert isinstance(doordash_app.current_state, RestaurantList) - assert doordash_app.navigation_stack == [] + assert isinstance(food_delivery_app.current_state, RestaurantList) + assert food_delivery_app.navigation_stack == [] -def test_open_restaurant_transition(doordash_app: StatefulDoordashApp) -> None: +def test_open_restaurant_transition(food_delivery_app: StatefulFoodDeliveryApp) -> None: """Handler: open_restaurant from RestaurantList transitions to RestaurantDetail.""" - state = _restaurant_list_state(doordash_app) - restaurant_id = doordash_app._restaurant1_id + state = _restaurant_list_state(food_delivery_app) + restaurant_id = food_delivery_app._restaurant1_id result = state.open_restaurant(restaurant_id) - event = _make_event(doordash_app, state.open_restaurant, result=result, restaurant_id=restaurant_id) - doordash_app.handle_state_transition(event) + event = _make_event(food_delivery_app, state.open_restaurant, result=result, restaurant_id=restaurant_id) + food_delivery_app.handle_state_transition(event) - assert isinstance(doordash_app.current_state, RestaurantDetail) - assert doordash_app.current_state.restaurant_id == restaurant_id + assert isinstance(food_delivery_app.current_state, RestaurantDetail) + assert food_delivery_app.current_state.restaurant_id == restaurant_id -def test_open_menu_item_transition(doordash_app: StatefulDoordashApp) -> None: +def test_open_menu_item_transition(food_delivery_app: StatefulFoodDeliveryApp) -> None: """Handler: open_menu_item from RestaurantDetail transitions to MenuItemDetail.""" - restaurant_id = doordash_app._restaurant1_id - item_id = doordash_app._item1_id - doordash_app.set_current_state(RestaurantDetail(restaurant_id)) + restaurant_id = food_delivery_app._restaurant1_id + item_id = food_delivery_app._item1_id + food_delivery_app.set_current_state(RestaurantDetail(restaurant_id)) - state = _restaurant_detail_state(doordash_app) + state = _restaurant_detail_state(food_delivery_app) result = state.open_menu_item(item_id) - event = _make_event(doordash_app, state.open_menu_item, result=result, item_id=item_id) - doordash_app.handle_state_transition(event) + event = _make_event(food_delivery_app, state.open_menu_item, result=result, item_id=item_id) + food_delivery_app.handle_state_transition(event) - assert isinstance(doordash_app.current_state, MenuItemDetail) - assert doordash_app.current_state.item_id == item_id - assert doordash_app.current_state.restaurant_id == restaurant_id + assert isinstance(food_delivery_app.current_state, MenuItemDetail) + assert food_delivery_app.current_state.item_id == item_id + assert food_delivery_app.current_state.restaurant_id == restaurant_id -def test_add_cart_transition(doordash_app: StatefulDoordashApp) -> None: +def test_add_cart_transition(food_delivery_app: StatefulFoodDeliveryApp) -> None: """Handler: add_cart from MenuItemDetail transitions to CartView.""" - restaurant_id = doordash_app._restaurant1_id - item_id = doordash_app._item1_id - doordash_app.set_current_state(MenuItemDetail(item_id, restaurant_id)) + restaurant_id = food_delivery_app._restaurant1_id + item_id = food_delivery_app._item1_id + food_delivery_app.set_current_state(MenuItemDetail(item_id, restaurant_id)) - state = _menu_item_detail_state(doordash_app) + state = _menu_item_detail_state(food_delivery_app) result = state.add_cart(item_id, quantity=1) - event = _make_event(doordash_app, state.add_cart, result=result, item_id=item_id, quantity=1) - doordash_app.handle_state_transition(event) + event = _make_event(food_delivery_app, state.add_cart, result=result, item_id=item_id, quantity=1) + food_delivery_app.handle_state_transition(event) - assert isinstance(doordash_app.current_state, CartView) + assert isinstance(food_delivery_app.current_state, CartView) -def test_submit_order_transition(doordash_app: StatefulDoordashApp) -> None: +def test_submit_order_transition(food_delivery_app: StatefulFoodDeliveryApp) -> None: """Handler: submit_order from CheckoutView transitions to OrderDetail.""" - item_id = doordash_app._item1_id - doordash_app.add_to_cart(item_id, quantity=1) - doordash_app.delivery_address = "123 Main St" - doordash_app.payment_method = "credit_card" - doordash_app.set_current_state(CheckoutView()) + item_id = food_delivery_app._item1_id + food_delivery_app.add_to_cart(item_id, quantity=1) + food_delivery_app.delivery_address = "123 Main St" + food_delivery_app.payment_method = "credit_card" + food_delivery_app.set_current_state(CheckoutView()) - state = _checkout_view_state(doordash_app) + state = _checkout_view_state(food_delivery_app) result = state.submit_order() - event = _make_event(doordash_app, state.submit_order, result=result) - doordash_app.handle_state_transition(event) + event = _make_event(food_delivery_app, state.submit_order, result=result) + food_delivery_app.handle_state_transition(event) - assert isinstance(doordash_app.current_state, OrderDetail) - assert doordash_app.current_state.order_id == result + assert isinstance(food_delivery_app.current_state, OrderDetail) + assert food_delivery_app.current_state.order_id == result -class TestDoordashEnvironmentIntegration: +class TestFoodDeliveryEnvironmentIntegration: """Integration tests that exercise the full environment flow.""" - def test_full_order_flow(self, env_with_doordash: StateAwareEnvironmentWrapper) -> None: + def test_full_order_flow(self, env_with_food_delivery: StateAwareEnvironmentWrapper) -> None: """Integration: RestaurantList -> RestaurantDetail -> MenuItemDetail -> CartView -> CheckoutView -> OrderDetail.""" - env = env_with_doordash - app = env.get_app_with_class(StatefulDoordashApp) + env = env_with_food_delivery + app = env.get_app_with_class(StatefulFoodDeliveryApp) assert isinstance(app.current_state, RestaurantList) assert len(app.navigation_stack) == 0 @@ -277,10 +277,10 @@ def test_full_order_flow(self, env_with_doordash: StateAwareEnvironmentWrapper) assert app.current_state.order_id == order_id assert len(app.navigation_stack) == 5 - def test_view_orders_flow(self, env_with_doordash: StateAwareEnvironmentWrapper) -> None: + def test_view_orders_flow(self, env_with_food_delivery: StateAwareEnvironmentWrapper) -> None: """Integration: RestaurantList -> OrderListView -> OrderDetail.""" - env = env_with_doordash - app = env.get_app_with_class(StatefulDoordashApp) + env = env_with_food_delivery + app = env.get_app_with_class(StatefulFoodDeliveryApp) # Create an order first restaurant_id = app._restaurant1_id @@ -302,10 +302,10 @@ def test_view_orders_flow(self, env_with_doordash: StateAwareEnvironmentWrapper) assert app.current_state.order_id == order_id assert len(app.navigation_stack) == 2 - def test_reorder_flow(self, env_with_doordash: StateAwareEnvironmentWrapper) -> None: + def test_reorder_flow(self, env_with_food_delivery: StateAwareEnvironmentWrapper) -> None: """Integration: OrderDetail -> reorder -> CartView -> CheckoutView.""" - env = env_with_doordash - app = env.get_app_with_class(StatefulDoordashApp) + env = env_with_food_delivery + app = env.get_app_with_class(StatefulFoodDeliveryApp) # Create an order first restaurant_id = app._restaurant1_id @@ -326,10 +326,10 @@ def test_reorder_flow(self, env_with_doordash: StateAwareEnvironmentWrapper) -> _cart_view_state(app).checkout() assert isinstance(app.current_state, CheckoutView) - def test_go_back_navigation(self, env_with_doordash: StateAwareEnvironmentWrapper) -> None: + def test_go_back_navigation(self, env_with_food_delivery: StateAwareEnvironmentWrapper) -> None: """Integration: Test go_back navigation through states.""" - env = env_with_doordash - app = env.get_app_with_class(StatefulDoordashApp) + env = env_with_food_delivery + app = env.get_app_with_class(StatefulFoodDeliveryApp) restaurant_id = app._restaurant1_id item_id = app._item1_id @@ -346,10 +346,10 @@ def test_go_back_navigation(self, env_with_doordash: StateAwareEnvironmentWrappe assert isinstance(app.current_state, RestaurantList) assert len(app.navigation_stack) == 0 - def test_cart_management(self, env_with_doordash: StateAwareEnvironmentWrapper) -> None: + def test_cart_management(self, env_with_food_delivery: StateAwareEnvironmentWrapper) -> None: """Integration: Test cart operations.""" - env = env_with_doordash - app = env.get_app_with_class(StatefulDoordashApp) + env = env_with_food_delivery + app = env.get_app_with_class(StatefulFoodDeliveryApp) item_id = app._item1_id app.add_to_cart(item_id, quantity=2) @@ -371,10 +371,10 @@ def test_cart_management(self, env_with_doordash: StateAwareEnvironmentWrapper) assert len(cart["items"]) == 0 assert cart["total"] == 0 - def test_multiple_items_cart_flow(self, env_with_doordash: StateAwareEnvironmentWrapper) -> None: + def test_multiple_items_cart_flow(self, env_with_food_delivery: StateAwareEnvironmentWrapper) -> None: """Integration: Add multiple items to cart, update quantities, then checkout.""" - env = env_with_doordash - app = env.get_app_with_class(StatefulDoordashApp) + env = env_with_food_delivery + app = env.get_app_with_class(StatefulFoodDeliveryApp) restaurant_id = app._restaurant1_id item1_id = app._item1_id @@ -410,51 +410,51 @@ def test_multiple_items_cart_flow(self, env_with_doordash: StateAwareEnvironment -def test_add_item_from_different_restaurant_error(doordash_app: StatefulDoordashApp) -> None: +def test_add_item_from_different_restaurant_error(food_delivery_app: StatefulFoodDeliveryApp) -> None: """Test that adding items from different restaurants to cart raises error.""" - item1_id = doordash_app._item1_id # From restaurant 1 - item3_id = doordash_app._item3_id # From restaurant 2 + item1_id = food_delivery_app._item1_id # From restaurant 1 + item3_id = food_delivery_app._item3_id # From restaurant 2 - doordash_app.add_to_cart(item1_id, quantity=1) + food_delivery_app.add_to_cart(item1_id, quantity=1) with pytest.raises(ValueError, match="different restaurant"): - doordash_app.add_to_cart(item3_id, quantity=1) + food_delivery_app.add_to_cart(item3_id, quantity=1) -def test_place_order_with_empty_cart_error(doordash_app: StatefulDoordashApp) -> None: +def test_place_order_with_empty_cart_error(food_delivery_app: StatefulFoodDeliveryApp) -> None: """Test that placing order with empty cart raises error.""" - doordash_app.delivery_address = "123 Main St" - doordash_app.payment_method = "credit_card" + food_delivery_app.delivery_address = "123 Main St" + food_delivery_app.payment_method = "credit_card" with pytest.raises(ValueError, match="Cart is empty"): - doordash_app.place_order() + food_delivery_app.place_order() -def test_place_order_without_address_error(doordash_app: StatefulDoordashApp) -> None: +def test_place_order_without_address_error(food_delivery_app: StatefulFoodDeliveryApp) -> None: """Test that placing order without address raises error.""" - item_id = doordash_app._item1_id - doordash_app.add_to_cart(item_id, quantity=1) - doordash_app.payment_method = "credit_card" + item_id = food_delivery_app._item1_id + food_delivery_app.add_to_cart(item_id, quantity=1) + food_delivery_app.payment_method = "credit_card" with pytest.raises(ValueError, match="Delivery address not set"): - doordash_app.place_order() + food_delivery_app.place_order() -def test_place_order_without_payment_error(doordash_app: StatefulDoordashApp) -> None: +def test_place_order_without_payment_error(food_delivery_app: StatefulFoodDeliveryApp) -> None: """Test that placing order without payment method raises error.""" - item_id = doordash_app._item1_id - doordash_app.add_to_cart(item_id, quantity=1) - doordash_app.delivery_address = "123 Main St" + item_id = food_delivery_app._item1_id + food_delivery_app.add_to_cart(item_id, quantity=1) + food_delivery_app.delivery_address = "123 Main St" with pytest.raises(ValueError, match="Payment method not set"): - doordash_app.place_order() + food_delivery_app.place_order() -def test_cancel_delivered_order_error(doordash_app: StatefulDoordashApp) -> None: +def test_cancel_delivered_order_error(food_delivery_app: StatefulFoodDeliveryApp) -> None: """Test that canceling a delivered order raises error.""" - restaurant_id = doordash_app._restaurant1_id - item_id = doordash_app._item1_id - order_id = doordash_app.create_order_with_time( + restaurant_id = food_delivery_app._restaurant1_id + item_id = food_delivery_app._item1_id + order_id = food_delivery_app.create_order_with_time( restaurant_id=restaurant_id, items=[{"item_id": item_id, "name": "Test Item", "price": 10.0, "quantity": 1}], delivery_address="123 Main St", @@ -464,18 +464,18 @@ def test_cancel_delivered_order_error(doordash_app: StatefulDoordashApp) -> None ) with pytest.raises(ValueError, match="Cannot cancel order with status"): - doordash_app.cancel_order(order_id) + food_delivery_app.cancel_order(order_id) -def test_save_and_load_state(doordash_app: StatefulDoordashApp) -> None: +def test_save_and_load_state(food_delivery_app: StatefulFoodDeliveryApp) -> None: """Test that app state can be saved and loaded correctly.""" - item_id = doordash_app._item1_id - doordash_app.add_to_cart(item_id, quantity=2) - doordash_app.delivery_address = "123 Main St" - doordash_app.payment_method = "credit_card" + item_id = food_delivery_app._item1_id + food_delivery_app.add_to_cart(item_id, quantity=2) + food_delivery_app.delivery_address = "123 Main St" + food_delivery_app.payment_method = "credit_card" - restaurant_id = doordash_app._restaurant1_id - order_id = doordash_app.create_order_with_time( + restaurant_id = food_delivery_app._restaurant1_id + order_id = food_delivery_app.create_order_with_time( restaurant_id=restaurant_id, items=[{"item_id": item_id, "name": "Test Item", "price": 10.0, "quantity": 1}], delivery_address="456 Oak Ave", @@ -483,35 +483,35 @@ def test_save_and_load_state(doordash_app: StatefulDoordashApp) -> None: order_date="2024-01-01 12:00:00", ) - state_dict = doordash_app.get_state() + state_dict = food_delivery_app.get_state() - new_app = StatefulDoordashApp(name="doordash2") + new_app = StatefulFoodDeliveryApp(name="food_delivery2") new_app.load_state(state_dict) - assert len(new_app.restaurants) == len(doordash_app.restaurants) - assert len(new_app.menu_items) == len(doordash_app.menu_items) - assert len(new_app.cart) == len(doordash_app.cart) - assert len(new_app.orders) == len(doordash_app.orders) - assert new_app.delivery_address == doordash_app.delivery_address - assert new_app.payment_method == doordash_app.payment_method - assert new_app.cart[item_id].quantity == doordash_app.cart[item_id].quantity - assert new_app.orders[order_id].order_status == doordash_app.orders[order_id].order_status + assert len(new_app.restaurants) == len(food_delivery_app.restaurants) + assert len(new_app.menu_items) == len(food_delivery_app.menu_items) + assert len(new_app.cart) == len(food_delivery_app.cart) + assert len(new_app.orders) == len(food_delivery_app.orders) + assert new_app.delivery_address == food_delivery_app.delivery_address + assert new_app.payment_method == food_delivery_app.payment_method + assert new_app.cart[item_id].quantity == food_delivery_app.cart[item_id].quantity + assert new_app.orders[order_id].order_status == food_delivery_app.orders[order_id].order_status -def test_reset_app(doordash_app: StatefulDoordashApp) -> None: +def test_reset_app(food_delivery_app: StatefulFoodDeliveryApp) -> None: """Test that reset clears all app state.""" - item_id = doordash_app._item1_id - doordash_app.add_to_cart(item_id, quantity=1) - doordash_app.delivery_address = "123 Main St" - doordash_app.payment_method = "credit_card" - - doordash_app.reset() - - assert len(doordash_app.restaurants) == 0 - assert len(doordash_app.menu_items) == 0 - assert len(doordash_app.cart) == 0 - assert len(doordash_app.orders) == 0 - assert doordash_app.delivery_address == "" - assert doordash_app.payment_method == "" - assert isinstance(doordash_app.current_state, RestaurantList) - assert len(doordash_app.navigation_stack) == 0 + item_id = food_delivery_app._item1_id + food_delivery_app.add_to_cart(item_id, quantity=1) + food_delivery_app.delivery_address = "123 Main St" + food_delivery_app.payment_method = "credit_card" + + food_delivery_app.reset() + + assert len(food_delivery_app.restaurants) == 0 + assert len(food_delivery_app.menu_items) == 0 + assert len(food_delivery_app.cart) == 0 + assert len(food_delivery_app.orders) == 0 + assert food_delivery_app.delivery_address == "" + assert food_delivery_app.payment_method == "" + assert isinstance(food_delivery_app.current_state, RestaurantList) + assert len(food_delivery_app.navigation_stack) == 0