Compare commits
15 Commits
dea8b39391
...
95010bfd5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95010bfd5d | ||
| deb40476a4 | |||
|
|
65d3099a7c | ||
| 8e02889fd4 | |||
|
|
b872a05397 | ||
| 6889499fff | |||
|
|
3c453c89ce | ||
| be4213aab6 | |||
|
|
4e75656225 | ||
| c30e0ad321 | |||
|
|
b57f91833b | ||
| 04efc46382 | |||
|
|
7e7aa46a73 | ||
| 91b367af93 | |||
|
|
3c24e03a0c |
@ -1,19 +1,18 @@
|
||||
# Gitea Actions: Docker Build, Push, and Notify
|
||||
#
|
||||
# CI/CD pipeline for Major Domo Discord Bot:
|
||||
# - Builds Docker images on merge to main/next-release
|
||||
# - Auto-generates CalVer version (YYYY.MM.BUILD) on main branch merges
|
||||
# - Supports multi-channel releases: stable (main), rc (next-release)
|
||||
# - Pushes to Docker Hub and creates git tag on main
|
||||
# - Triggered by pushing a CalVer tag (e.g., 2026.3.11)
|
||||
# - Builds Docker image and pushes to Docker Hub with version + production tags
|
||||
# - Sends Discord notifications on success/failure
|
||||
#
|
||||
# To release: git tag 2026.3.11 && git push --tags
|
||||
|
||||
name: Build Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- next-release
|
||||
tags:
|
||||
- '20*' # matches CalVer tags like 2026.3.11
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -23,7 +22,16 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history for tag counting
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "sha_short=$SHA_SHORT" >> $GITHUB_OUTPUT
|
||||
echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://github.com/docker/setup-buildx-action@v3
|
||||
@ -34,67 +42,47 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Generate CalVer version
|
||||
id: calver
|
||||
uses: cal/gitea-actions/calver@main
|
||||
|
||||
- name: Resolve Docker tags
|
||||
id: tags
|
||||
uses: cal/gitea-actions/docker-tags@main
|
||||
with:
|
||||
image: manticorum67/major-domo-discordapp
|
||||
version: ${{ steps.calver.outputs.version }}
|
||||
sha_short: ${{ steps.calver.outputs.sha_short }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: https://github.com/docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
tags: |
|
||||
manticorum67/major-domo-discordapp:${{ steps.version.outputs.version }}
|
||||
manticorum67/major-domo-discordapp:production
|
||||
cache-from: type=registry,ref=manticorum67/major-domo-discordapp:buildcache
|
||||
cache-to: type=registry,ref=manticorum67/major-domo-discordapp:buildcache,mode=max
|
||||
|
||||
- name: Tag release
|
||||
if: success() && github.ref == 'refs/heads/main'
|
||||
uses: cal/gitea-actions/gitea-tag@main
|
||||
with:
|
||||
version: ${{ steps.calver.outputs.version }}
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "## Docker Build Successful" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Channel:** \`${{ steps.tags.outputs.channel }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** \`${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY
|
||||
IFS=',' read -ra TAG_ARRAY <<< "${{ steps.tags.outputs.tags }}"
|
||||
for tag in "${TAG_ARRAY[@]}"; do
|
||||
echo "- \`${tag}\`" >> $GITHUB_STEP_SUMMARY
|
||||
done
|
||||
echo "- \`manticorum67/major-domo-discordapp:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`manticorum67/major-domo-discordapp:production\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Branch: \`${{ steps.calver.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Timestamp: \`${{ steps.calver.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Commit: \`${{ steps.version.outputs.sha_short }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Timestamp: \`${{ steps.version.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Pull with: \`docker pull manticorum67/major-domo-discordapp:${{ steps.tags.outputs.primary_tag }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Pull with: \`docker pull manticorum67/major-domo-discordapp:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Discord Notification - Success
|
||||
if: success() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next-release')
|
||||
if: success()
|
||||
uses: cal/gitea-actions/discord-notify@main
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
title: "Major Domo Bot"
|
||||
status: success
|
||||
version: ${{ steps.calver.outputs.version }}
|
||||
image_tag: ${{ steps.tags.outputs.primary_tag }}
|
||||
commit_sha: ${{ steps.calver.outputs.sha_short }}
|
||||
timestamp: ${{ steps.calver.outputs.timestamp }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
image_tag: ${{ steps.version.outputs.version }}
|
||||
commit_sha: ${{ steps.version.outputs.sha_short }}
|
||||
timestamp: ${{ steps.version.outputs.timestamp }}
|
||||
|
||||
- name: Discord Notification - Failure
|
||||
if: failure() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next-release')
|
||||
if: failure()
|
||||
uses: cal/gitea-actions/discord-notify@main
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
|
||||
@ -7,11 +7,11 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SSH_CMD="ssh -i ~/.ssh/cloud_servers_rsa root@akamai"
|
||||
SSH_CMD="ssh akamai"
|
||||
REMOTE_DIR="/root/container-data/major-domo"
|
||||
SERVICE="discord-app"
|
||||
CONTAINER="major-domo-discord-app-1"
|
||||
IMAGE="manticorum67/major-domo-discordapp:latest"
|
||||
IMAGE="manticorum67/major-domo-discordapp:production"
|
||||
|
||||
SKIP_CONFIRM=false
|
||||
[[ "${1:-}" == "-y" ]] && SKIP_CONFIRM=true
|
||||
@ -19,9 +19,9 @@ SKIP_CONFIRM=false
|
||||
# --- Pre-deploy checks ---
|
||||
|
||||
if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then
|
||||
echo "WARNING: You have uncommitted changes."
|
||||
git status --short
|
||||
echo ""
|
||||
echo "WARNING: You have uncommitted changes."
|
||||
git status --short
|
||||
echo ""
|
||||
fi
|
||||
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
||||
@ -32,9 +32,12 @@ echo "Target: akamai (${IMAGE})"
|
||||
echo ""
|
||||
|
||||
if [[ "$SKIP_CONFIRM" != true ]]; then
|
||||
read -rp "Deploy to production? [y/N] " answer
|
||||
[[ "$answer" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
|
||||
echo ""
|
||||
read -rp "Deploy to production? [y/N] " answer
|
||||
[[ "$answer" =~ ^[Yy]$ ]] || {
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
}
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# --- Save previous image for rollback ---
|
||||
@ -64,16 +67,16 @@ echo ""
|
||||
echo "==> Image digest: ${NEW_DIGEST}"
|
||||
|
||||
if [[ "$PREV_DIGEST" == "$NEW_DIGEST" ]]; then
|
||||
echo " (unchanged from previous deploy)"
|
||||
echo " (unchanged from previous deploy)"
|
||||
fi
|
||||
|
||||
# --- Rollback command ---
|
||||
|
||||
if [[ "$PREV_DIGEST" != "unknown" && "$PREV_DIGEST" != "$NEW_DIGEST" ]]; then
|
||||
echo ""
|
||||
echo "==> To rollback:"
|
||||
echo " ssh -i ~/.ssh/cloud_servers_rsa root@akamai \\"
|
||||
echo " \"cd ${REMOTE_DIR} && docker pull ${PREV_DIGEST} && docker tag ${PREV_DIGEST} ${IMAGE} && docker compose up -d ${SERVICE}\""
|
||||
echo ""
|
||||
echo "==> To rollback:"
|
||||
echo " ssh akamai \\"
|
||||
echo " \"cd ${REMOTE_DIR} && docker pull ${PREV_DIGEST} && docker tag ${PREV_DIGEST} ${IMAGE} && docker compose up -d ${SERVICE}\""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
99
.scripts/release.sh
Executable file
99
.scripts/release.sh
Executable file
@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create a CalVer release tag and push to trigger CI.
|
||||
#
|
||||
# Usage:
|
||||
# .scripts/release.sh # auto-generates next version (YYYY.M.BUILD)
|
||||
# .scripts/release.sh 2026.3.11 # explicit version
|
||||
# .scripts/release.sh -y # auto-generate + skip confirmation
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKIP_CONFIRM=false
|
||||
VERSION=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-y) SKIP_CONFIRM=true ;;
|
||||
*) VERSION="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Ensure we're on main and up to date ---
|
||||
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [[ "$BRANCH" != "main" ]]; then
|
||||
echo "ERROR: Must be on main branch (currently on ${BRANCH})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Fetching latest..."
|
||||
git fetch origin main --tags --quiet
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse origin/main)
|
||||
if [[ "$LOCAL" != "$REMOTE" ]]; then
|
||||
echo "ERROR: Local main is not up to date with origin. Run: git pull"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Determine version ---
|
||||
|
||||
YEAR=$(date +%Y)
|
||||
MONTH=$(date +%-m) # no leading zero
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
# Find the highest build number for this year.month
|
||||
LAST_BUILD=$(git tag --list "${YEAR}.${MONTH}.*" --sort=-v:refname | head -1 | awk -F. '{print $3}')
|
||||
NEXT_BUILD=$((${LAST_BUILD:-0} + 1))
|
||||
VERSION="${YEAR}.${MONTH}.${NEXT_BUILD}"
|
||||
fi
|
||||
|
||||
# Validate format
|
||||
if [[ ! "$VERSION" =~ ^20[0-9]{2}\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "ERROR: Invalid version format '${VERSION}'. Expected YYYY.M.BUILD (e.g., 2026.3.11)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check tag doesn't already exist
|
||||
if git rev-parse "refs/tags/${VERSION}" &>/dev/null; then
|
||||
echo "ERROR: Tag ${VERSION} already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Show what's being released ---
|
||||
|
||||
LAST_TAG=$(git tag --sort=-v:refname | head -1)
|
||||
echo ""
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Previous: ${LAST_TAG:-none}"
|
||||
echo "Commit: $(git log -1 --format='%h %s')"
|
||||
echo ""
|
||||
|
||||
if [[ -n "$LAST_TAG" ]]; then
|
||||
COMMIT_COUNT=$(git rev-list "${LAST_TAG}..HEAD" --count)
|
||||
echo "Changes since ${LAST_TAG} (${COMMIT_COUNT} commits):"
|
||||
git log "${LAST_TAG}..HEAD" --oneline --no-merges
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# --- Confirm ---
|
||||
|
||||
if [[ "$SKIP_CONFIRM" != true ]]; then
|
||||
read -rp "Create tag ${VERSION} and trigger release? [y/N] " answer
|
||||
[[ "$answer" =~ ^[Yy]$ ]] || {
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
}
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# --- Tag and push ---
|
||||
|
||||
git tag "$VERSION"
|
||||
git push origin tag "$VERSION"
|
||||
|
||||
echo ""
|
||||
echo "==> Tag ${VERSION} pushed. CI will build:"
|
||||
echo " - manticorum67/major-domo-discordapp:${VERSION}"
|
||||
echo " - manticorum67/major-domo-discordapp:production"
|
||||
echo ""
|
||||
echo "Deploy with: .scripts/deploy.sh"
|
||||
18
CLAUDE.md
18
CLAUDE.md
@ -16,15 +16,13 @@ manticorum67/major-domo-discordapp
|
||||
There is NO DASH between "discord" and "app". Not `discord-app`, not `discordapp-v2`.
|
||||
|
||||
### Git Workflow
|
||||
NEVER commit directly to `main` or `next-release`. Always use feature branches.
|
||||
NEVER commit directly to `main`. Always use feature branches.
|
||||
|
||||
**Branch from `next-release`** for normal work targeting the next release:
|
||||
```bash
|
||||
git checkout -b feature/name origin/next-release # or fix/name, refactor/name
|
||||
git checkout -b feature/name origin/main # or fix/name, refactor/name
|
||||
```
|
||||
**Branch from `main`** only for urgent hotfixes that bypass the release cycle.
|
||||
|
||||
PRs go to `next-release` (staging), then `next-release → main` when releasing.
|
||||
PRs go to `main`. CI builds the Docker image and creates a CalVer tag on merge.
|
||||
|
||||
### Double Emoji in Embeds
|
||||
`EmbedTemplate.success/error/warning/info/loading()` auto-add emoji prefixes.
|
||||
@ -63,13 +61,13 @@ class MyCog(commands.Cog):
|
||||
- **Container**: `major-domo-discord-app-1`
|
||||
- **Image**: `manticorum67/major-domo-discordapp` (no dash between discord and app)
|
||||
- **Health**: Process liveness only (no HTTP endpoint)
|
||||
- **CI/CD**: Gitea Actions on PR to `main` — builds Docker image, auto-generates CalVer version (`YYYY.MM.BUILD`) on merge
|
||||
- **CI/CD**: Gitea Actions — tag-triggered Docker builds (push a CalVer tag to release)
|
||||
|
||||
### Release Workflow
|
||||
1. Create feature/fix branches off `next-release` (e.g., `fix/scorebug-bugs`)
|
||||
2. When done, merge the branch into `next-release` — this is the staging branch where changes accumulate
|
||||
3. When ready to release, open a PR from `next-release` → `main`
|
||||
4. CI builds Docker image on PR; CalVer tag is created on merge
|
||||
1. Create feature/fix branches off `main` (e.g., `fix/scorebug-bugs`)
|
||||
2. Open a PR to `main` when ready — merging does NOT trigger a build
|
||||
3. When ready to release: `git tag YYYY.M.BUILD && git push --tags`
|
||||
4. CI builds Docker image, tags it with the version + `production`, notifies Discord
|
||||
5. Deploy the new image to production (see `/deploy` skill)
|
||||
- **Other services on same host**: `sba_db_api`, `sba_postgres`, `sba_redis`, `sba-website-sba-web-1`, `pd_api`
|
||||
|
||||
|
||||
@ -61,7 +61,7 @@ class ScorecardTracker:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save scorecard data: {e}")
|
||||
|
||||
def publish_scorecard(
|
||||
async def publish_scorecard(
|
||||
self, text_channel_id: int, sheet_url: str, publisher_id: int
|
||||
) -> None:
|
||||
"""
|
||||
@ -82,7 +82,7 @@ class ScorecardTracker:
|
||||
self.save_data()
|
||||
logger.info(f"Published scorecard to channel {text_channel_id}: {sheet_url}")
|
||||
|
||||
def unpublish_scorecard(self, text_channel_id: int) -> bool:
|
||||
async def unpublish_scorecard(self, text_channel_id: int) -> bool:
|
||||
"""
|
||||
Remove scorecard from a text channel.
|
||||
|
||||
@ -103,7 +103,7 @@ class ScorecardTracker:
|
||||
|
||||
return False
|
||||
|
||||
def get_scorecard(self, text_channel_id: int) -> Optional[str]:
|
||||
async def get_scorecard(self, text_channel_id: int) -> Optional[str]:
|
||||
"""
|
||||
Get scorecard URL for a text channel.
|
||||
|
||||
@ -118,7 +118,7 @@ class ScorecardTracker:
|
||||
scorecard_data = scorecards.get(str(text_channel_id))
|
||||
return scorecard_data["sheet_url"] if scorecard_data else None
|
||||
|
||||
def get_all_scorecards(self) -> List[Tuple[int, str]]:
|
||||
async def get_all_scorecards(self) -> List[Tuple[int, str]]:
|
||||
"""
|
||||
Get all published scorecards.
|
||||
|
||||
@ -132,7 +132,7 @@ class ScorecardTracker:
|
||||
for channel_id, data in scorecards.items()
|
||||
]
|
||||
|
||||
def update_timestamp(self, text_channel_id: int) -> None:
|
||||
async def update_timestamp(self, text_channel_id: int) -> None:
|
||||
"""
|
||||
Update the last_updated timestamp for a scorecard.
|
||||
|
||||
@ -146,7 +146,7 @@ class ScorecardTracker:
|
||||
scorecards[channel_key]["last_updated"] = datetime.now(UTC).isoformat()
|
||||
self.save_data()
|
||||
|
||||
def cleanup_stale_entries(self, valid_channel_ids: List[int]) -> int:
|
||||
async def cleanup_stale_entries(self, valid_channel_ids: List[int]) -> int:
|
||||
"""
|
||||
Remove tracking entries for text channels that no longer exist.
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ from services.trade_builder import (
|
||||
clear_trade_builder,
|
||||
clear_trade_builder_by_team,
|
||||
)
|
||||
from services.league_service import league_service
|
||||
from services.player_service import player_service
|
||||
from services.team_service import team_service
|
||||
from models.team import RosterType
|
||||
@ -130,6 +131,22 @@ class TradeCommands(commands.Cog):
|
||||
)
|
||||
return
|
||||
|
||||
# Check trade deadline
|
||||
current = await league_service.get_current_state()
|
||||
if not current:
|
||||
await interaction.followup.send(
|
||||
"❌ Could not retrieve league state. Please try again later.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
if current.is_past_trade_deadline:
|
||||
await interaction.followup.send(
|
||||
f"❌ **The trade deadline has passed.** The deadline was Week {current.trade_deadline} "
|
||||
f"and we are currently in Week {current.week}. No new trades can be initiated.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Clear any existing trade and create new one
|
||||
clear_trade_builder(interaction.user.id)
|
||||
trade_builder = get_trade_builder(interaction.user.id, user_team)
|
||||
|
||||
@ -128,7 +128,7 @@ class VoiceChannelCleanupService:
|
||||
if channel_data and channel_data.get("text_channel_id"):
|
||||
try:
|
||||
text_channel_id_int = int(channel_data["text_channel_id"])
|
||||
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
|
||||
was_unpublished = await self.scorecard_tracker.unpublish_scorecard(
|
||||
text_channel_id_int
|
||||
)
|
||||
if was_unpublished:
|
||||
@ -218,8 +218,10 @@ class VoiceChannelCleanupService:
|
||||
if text_channel_id:
|
||||
try:
|
||||
text_channel_id_int = int(text_channel_id)
|
||||
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
|
||||
text_channel_id_int
|
||||
was_unpublished = (
|
||||
await self.scorecard_tracker.unpublish_scorecard(
|
||||
text_channel_id_int
|
||||
)
|
||||
)
|
||||
if was_unpublished:
|
||||
self.logger.info(
|
||||
@ -244,8 +246,10 @@ class VoiceChannelCleanupService:
|
||||
if text_channel_id:
|
||||
try:
|
||||
text_channel_id_int = int(text_channel_id)
|
||||
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
|
||||
text_channel_id_int
|
||||
was_unpublished = (
|
||||
await self.scorecard_tracker.unpublish_scorecard(
|
||||
text_channel_id_int
|
||||
)
|
||||
)
|
||||
if was_unpublished:
|
||||
self.logger.info(
|
||||
@ -330,7 +334,7 @@ class VoiceChannelCleanupService:
|
||||
if text_channel_id:
|
||||
try:
|
||||
text_channel_id_int = int(text_channel_id)
|
||||
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
|
||||
was_unpublished = await self.scorecard_tracker.unpublish_scorecard(
|
||||
text_channel_id_int
|
||||
)
|
||||
if was_unpublished:
|
||||
@ -358,7 +362,7 @@ class VoiceChannelCleanupService:
|
||||
if text_channel_id:
|
||||
try:
|
||||
text_channel_id_int = int(text_channel_id)
|
||||
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
|
||||
was_unpublished = await self.scorecard_tracker.unpublish_scorecard(
|
||||
text_channel_id_int
|
||||
)
|
||||
if was_unpublished:
|
||||
|
||||
@ -3,6 +3,7 @@ Current league state model
|
||||
|
||||
Represents the current state of the league including week, season, and settings.
|
||||
"""
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from models.base import SBABaseModel
|
||||
@ -10,38 +11,45 @@ from models.base import SBABaseModel
|
||||
|
||||
class Current(SBABaseModel):
|
||||
"""Model representing current league state and settings."""
|
||||
|
||||
|
||||
week: int = Field(69, description="Current week number")
|
||||
season: int = Field(69, description="Current season number")
|
||||
freeze: bool = Field(True, description="Whether league is frozen")
|
||||
bet_week: str = Field('sheets', description="Betting week identifier")
|
||||
bet_week: str = Field("sheets", description="Betting week identifier")
|
||||
trade_deadline: int = Field(1, description="Trade deadline week")
|
||||
pick_trade_start: int = Field(69, description="Draft pick trading start week")
|
||||
pick_trade_end: int = Field(420, description="Draft pick trading end week")
|
||||
playoffs_begin: int = Field(420, description="Week when playoffs begin")
|
||||
|
||||
|
||||
@field_validator("bet_week", mode="before")
|
||||
@classmethod
|
||||
def cast_bet_week_to_string(cls, v):
|
||||
"""Ensure bet_week is always a string."""
|
||||
return str(v) if v is not None else 'sheets'
|
||||
|
||||
return str(v) if v is not None else "sheets"
|
||||
|
||||
@property
|
||||
def is_offseason(self) -> bool:
|
||||
"""Check if league is currently in offseason."""
|
||||
return self.week > 18
|
||||
|
||||
|
||||
@property
|
||||
def is_playoffs(self) -> bool:
|
||||
"""Check if league is currently in playoffs."""
|
||||
return self.week >= self.playoffs_begin
|
||||
|
||||
|
||||
@property
|
||||
def can_trade_picks(self) -> bool:
|
||||
"""Check if draft pick trading is currently allowed."""
|
||||
return self.pick_trade_start <= self.week <= self.pick_trade_end
|
||||
|
||||
|
||||
@property
|
||||
def ever_trade_picks(self) -> bool:
|
||||
"""Check if draft pick trading is allowed this season at all"""
|
||||
return self.pick_trade_start <= self.playoffs_begin + 4
|
||||
return self.pick_trade_start <= self.playoffs_begin + 4
|
||||
|
||||
@property
|
||||
def is_past_trade_deadline(self) -> bool:
|
||||
"""Check if the trade deadline has passed."""
|
||||
if self.is_offseason:
|
||||
return False
|
||||
return self.week > self.trade_deadline
|
||||
|
||||
@ -4,6 +4,7 @@ Chart Service for managing gameplay charts and infographics.
|
||||
This service handles loading, saving, and managing chart definitions
|
||||
from the JSON configuration file.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@ -18,6 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class Chart:
|
||||
"""Represents a gameplay chart or infographic."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
category: str
|
||||
@ -27,17 +29,17 @@ class Chart:
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert chart to dictionary (excluding key)."""
|
||||
return {
|
||||
'name': self.name,
|
||||
'category': self.category,
|
||||
'description': self.description,
|
||||
'urls': self.urls
|
||||
"name": self.name,
|
||||
"category": self.category,
|
||||
"description": self.description,
|
||||
"urls": self.urls,
|
||||
}
|
||||
|
||||
|
||||
class ChartService:
|
||||
"""Service for managing gameplay charts and infographics."""
|
||||
|
||||
CHARTS_FILE = Path(__file__).parent.parent / 'data' / 'charts.json'
|
||||
CHARTS_FILE = Path(__file__).parent.parent / "storage" / "charts.json"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the chart service."""
|
||||
@ -54,21 +56,21 @@ class ChartService:
|
||||
self._categories = {}
|
||||
return
|
||||
|
||||
with open(self.CHARTS_FILE, 'r') as f:
|
||||
with open(self.CHARTS_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Load categories
|
||||
self._categories = data.get('categories', {})
|
||||
self._categories = data.get("categories", {})
|
||||
|
||||
# Load charts
|
||||
charts_data = data.get('charts', {})
|
||||
charts_data = data.get("charts", {})
|
||||
for key, chart_data in charts_data.items():
|
||||
self._charts[key] = Chart(
|
||||
key=key,
|
||||
name=chart_data['name'],
|
||||
category=chart_data['category'],
|
||||
description=chart_data.get('description', ''),
|
||||
urls=chart_data.get('urls', [])
|
||||
name=chart_data["name"],
|
||||
category=chart_data["category"],
|
||||
description=chart_data.get("description", ""),
|
||||
urls=chart_data.get("urls", []),
|
||||
)
|
||||
|
||||
logger.info(f"Loaded {len(self._charts)} charts from {self.CHARTS_FILE}")
|
||||
@ -81,20 +83,17 @@ class ChartService:
|
||||
def _save_charts(self) -> None:
|
||||
"""Save charts to JSON file."""
|
||||
try:
|
||||
# Ensure data directory exists
|
||||
# Ensure storage directory exists
|
||||
self.CHARTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build data structure
|
||||
data = {
|
||||
'charts': {
|
||||
key: chart.to_dict()
|
||||
for key, chart in self._charts.items()
|
||||
},
|
||||
'categories': self._categories
|
||||
"charts": {key: chart.to_dict() for key, chart in self._charts.items()},
|
||||
"categories": self._categories,
|
||||
}
|
||||
|
||||
# Write to file
|
||||
with open(self.CHARTS_FILE, 'w') as f:
|
||||
with open(self.CHARTS_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
logger.info(f"Saved {len(self._charts)} charts to {self.CHARTS_FILE}")
|
||||
@ -134,10 +133,7 @@ class ChartService:
|
||||
Returns:
|
||||
List of charts in the specified category
|
||||
"""
|
||||
return [
|
||||
chart for chart in self._charts.values()
|
||||
if chart.category == category
|
||||
]
|
||||
return [chart for chart in self._charts.values() if chart.category == category]
|
||||
|
||||
def get_chart_keys(self) -> List[str]:
|
||||
"""
|
||||
@ -157,8 +153,9 @@ class ChartService:
|
||||
"""
|
||||
return self._categories.copy()
|
||||
|
||||
def add_chart(self, key: str, name: str, category: str,
|
||||
urls: List[str], description: str = "") -> None:
|
||||
def add_chart(
|
||||
self, key: str, name: str, category: str, urls: List[str], description: str = ""
|
||||
) -> None:
|
||||
"""
|
||||
Add a new chart.
|
||||
|
||||
@ -176,18 +173,19 @@ class ChartService:
|
||||
raise BotException(f"Chart '{key}' already exists")
|
||||
|
||||
self._charts[key] = Chart(
|
||||
key=key,
|
||||
name=name,
|
||||
category=category,
|
||||
description=description,
|
||||
urls=urls
|
||||
key=key, name=name, category=category, description=description, urls=urls
|
||||
)
|
||||
self._save_charts()
|
||||
logger.info(f"Added chart: {key}")
|
||||
|
||||
def update_chart(self, key: str, name: Optional[str] = None,
|
||||
category: Optional[str] = None, urls: Optional[List[str]] = None,
|
||||
description: Optional[str] = None) -> None:
|
||||
def update_chart(
|
||||
self,
|
||||
key: str,
|
||||
name: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
urls: Optional[List[str]] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Update an existing chart.
|
||||
|
||||
|
||||
@ -468,21 +468,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
|
||||
|
||||
commands_data = await self.get_items_with_params(params)
|
||||
|
||||
creators = await asyncio.gather(
|
||||
*[
|
||||
self.get_creator_by_id(cmd_data.creator_id)
|
||||
for cmd_data in commands_data
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
commands = []
|
||||
for cmd_data in commands_data:
|
||||
try:
|
||||
creator = await self.get_creator_by_id(cmd_data.creator_id)
|
||||
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
|
||||
except BotException as e:
|
||||
# Handle missing creator gracefully
|
||||
for cmd_data, creator in zip(commands_data, creators):
|
||||
if isinstance(creator, BotException):
|
||||
self.logger.warning(
|
||||
"Skipping popular command with missing creator",
|
||||
command_id=cmd_data.id,
|
||||
command_name=cmd_data.name,
|
||||
creator_id=cmd_data.creator_id,
|
||||
error=str(e),
|
||||
error=str(creator),
|
||||
)
|
||||
continue
|
||||
if isinstance(creator, BaseException):
|
||||
raise creator
|
||||
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
|
||||
|
||||
return commands
|
||||
|
||||
@ -670,21 +677,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
|
||||
|
||||
commands_data = await self.get_items_with_params(params)
|
||||
|
||||
creators = await asyncio.gather(
|
||||
*[
|
||||
self.get_creator_by_id(cmd_data.creator_id)
|
||||
for cmd_data in commands_data
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
commands = []
|
||||
for cmd_data in commands_data:
|
||||
try:
|
||||
creator = await self.get_creator_by_id(cmd_data.creator_id)
|
||||
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
|
||||
except BotException as e:
|
||||
# Handle missing creator gracefully
|
||||
for cmd_data, creator in zip(commands_data, creators):
|
||||
if isinstance(creator, BotException):
|
||||
self.logger.warning(
|
||||
"Skipping command with missing creator",
|
||||
command_id=cmd_data.id,
|
||||
command_name=cmd_data.name,
|
||||
creator_id=cmd_data.creator_id,
|
||||
error=str(e),
|
||||
error=str(creator),
|
||||
)
|
||||
continue
|
||||
if isinstance(creator, BaseException):
|
||||
raise creator
|
||||
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
|
||||
|
||||
return commands
|
||||
|
||||
@ -696,21 +710,28 @@ class CustomCommandsService(BaseService[CustomCommand]):
|
||||
|
||||
commands_data = await self.get_items_with_params(params)
|
||||
|
||||
creators = await asyncio.gather(
|
||||
*[
|
||||
self.get_creator_by_id(cmd_data.creator_id)
|
||||
for cmd_data in commands_data
|
||||
],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
commands = []
|
||||
for cmd_data in commands_data:
|
||||
try:
|
||||
creator = await self.get_creator_by_id(cmd_data.creator_id)
|
||||
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
|
||||
except BotException as e:
|
||||
# Handle missing creator gracefully
|
||||
for cmd_data, creator in zip(commands_data, creators):
|
||||
if isinstance(creator, BotException):
|
||||
self.logger.warning(
|
||||
"Skipping command with missing creator",
|
||||
command_id=cmd_data.id,
|
||||
command_name=cmd_data.name,
|
||||
creator_id=cmd_data.creator_id,
|
||||
error=str(e),
|
||||
error=str(creator),
|
||||
)
|
||||
continue
|
||||
if isinstance(creator, BaseException):
|
||||
raise creator
|
||||
commands.append(CustomCommand(**cmd_data.model_dump(), creator=creator))
|
||||
|
||||
return commands
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ Decision Service
|
||||
Manages pitching decision operations for game submission.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
|
||||
from utils.logging import get_contextual_logger
|
||||
@ -124,22 +125,19 @@ class DecisionService:
|
||||
if int(decision.get("b_save", 0)) == 1:
|
||||
bsv_ids.append(pitcher_id)
|
||||
|
||||
# Second pass: Fetch Player objects
|
||||
wp = await player_service.get_player(wp_id) if wp_id else None
|
||||
lp = await player_service.get_player(lp_id) if lp_id else None
|
||||
sv = await player_service.get_player(sv_id) if sv_id else None
|
||||
# Second pass: Fetch all Player objects in parallel
|
||||
# Order: [wp_id, lp_id, sv_id, *hold_ids, *bsv_ids]; None IDs resolve immediately
|
||||
ordered_ids = [wp_id, lp_id, sv_id] + hold_ids + bsv_ids
|
||||
results = await asyncio.gather(
|
||||
*[
|
||||
player_service.get_player(pid) if pid else asyncio.sleep(0, result=None)
|
||||
for pid in ordered_ids
|
||||
]
|
||||
)
|
||||
|
||||
holders = []
|
||||
for hold_id in hold_ids:
|
||||
holder = await player_service.get_player(hold_id)
|
||||
if holder:
|
||||
holders.append(holder)
|
||||
|
||||
blown_saves = []
|
||||
for bsv_id in bsv_ids:
|
||||
bsv = await player_service.get_player(bsv_id)
|
||||
if bsv:
|
||||
blown_saves.append(bsv)
|
||||
wp, lp, sv = results[0], results[1], results[2]
|
||||
holders = [p for p in results[3 : 3 + len(hold_ids)] if p]
|
||||
blown_saves = [p for p in results[3 + len(hold_ids) :] if p]
|
||||
|
||||
return wp, lp, sv, holders, blown_saves
|
||||
|
||||
|
||||
143
tests/test_commands_trade_deadline.py
Normal file
143
tests/test_commands_trade_deadline.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""
|
||||
Tests for trade deadline enforcement in /trade commands.
|
||||
|
||||
Validates that trades are blocked after the trade deadline and allowed during/before it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from tests.factories import CurrentFactory, TeamFactory
|
||||
|
||||
|
||||
class TestTradeInitiateDeadlineGuard:
|
||||
"""Test trade deadline enforcement in /trade initiate command."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_interaction(self):
|
||||
"""Create mock Discord interaction with deferred response."""
|
||||
interaction = AsyncMock()
|
||||
interaction.user = MagicMock()
|
||||
interaction.user.id = 258104532423147520
|
||||
interaction.response = AsyncMock()
|
||||
interaction.followup = AsyncMock()
|
||||
interaction.guild = MagicMock()
|
||||
interaction.guild.id = 669356687294988350
|
||||
return interaction
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trade_initiate_blocked_past_deadline(self, mock_interaction):
|
||||
"""After the trade deadline, /trade initiate should return a friendly error."""
|
||||
user_team = TeamFactory.west_virginia()
|
||||
other_team = TeamFactory.new_york()
|
||||
past_deadline = CurrentFactory.create(week=15, trade_deadline=14)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"commands.transactions.trade.validate_user_has_team",
|
||||
new_callable=AsyncMock,
|
||||
return_value=user_team,
|
||||
),
|
||||
patch(
|
||||
"commands.transactions.trade.get_team_by_abbrev_with_validation",
|
||||
new_callable=AsyncMock,
|
||||
return_value=other_team,
|
||||
),
|
||||
patch("commands.transactions.trade.league_service") as mock_league,
|
||||
):
|
||||
mock_league.get_current_state = AsyncMock(return_value=past_deadline)
|
||||
|
||||
from commands.transactions.trade import TradeCommands
|
||||
|
||||
bot = MagicMock()
|
||||
cog = TradeCommands(bot)
|
||||
await cog.trade_initiate.callback(cog, mock_interaction, "NY")
|
||||
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
call_kwargs = mock_interaction.followup.send.call_args
|
||||
msg = (
|
||||
call_kwargs[0][0]
|
||||
if call_kwargs[0]
|
||||
else call_kwargs[1].get("content", "")
|
||||
)
|
||||
assert "trade deadline has passed" in msg.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trade_initiate_allowed_at_deadline_week(self, mock_interaction):
|
||||
"""During the deadline week itself, /trade initiate should proceed."""
|
||||
user_team = TeamFactory.west_virginia()
|
||||
other_team = TeamFactory.new_york()
|
||||
at_deadline = CurrentFactory.create(week=14, trade_deadline=14)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"commands.transactions.trade.validate_user_has_team",
|
||||
new_callable=AsyncMock,
|
||||
return_value=user_team,
|
||||
),
|
||||
patch(
|
||||
"commands.transactions.trade.get_team_by_abbrev_with_validation",
|
||||
new_callable=AsyncMock,
|
||||
return_value=other_team,
|
||||
),
|
||||
patch("commands.transactions.trade.league_service") as mock_league,
|
||||
patch("commands.transactions.trade.clear_trade_builder") as mock_clear,
|
||||
patch("commands.transactions.trade.get_trade_builder") as mock_get_builder,
|
||||
patch(
|
||||
"commands.transactions.trade.create_trade_embed",
|
||||
new_callable=AsyncMock,
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
):
|
||||
mock_league.get_current_state = AsyncMock(return_value=at_deadline)
|
||||
mock_builder = MagicMock()
|
||||
mock_builder.add_team = AsyncMock(return_value=(True, None))
|
||||
mock_builder.trade_id = "test-123"
|
||||
mock_get_builder.return_value = mock_builder
|
||||
|
||||
from commands.transactions.trade import TradeCommands
|
||||
|
||||
bot = MagicMock()
|
||||
cog = TradeCommands(bot)
|
||||
cog.channel_manager = MagicMock()
|
||||
cog.channel_manager.create_trade_channel = AsyncMock(return_value=None)
|
||||
await cog.trade_initiate.callback(cog, mock_interaction, "NY")
|
||||
|
||||
# Should have proceeded past deadline check to clear/create trade
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trade_initiate_blocked_when_current_none(self, mock_interaction):
|
||||
"""When league state can't be fetched, /trade initiate should fail closed."""
|
||||
user_team = TeamFactory.west_virginia()
|
||||
other_team = TeamFactory.new_york()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"commands.transactions.trade.validate_user_has_team",
|
||||
new_callable=AsyncMock,
|
||||
return_value=user_team,
|
||||
),
|
||||
patch(
|
||||
"commands.transactions.trade.get_team_by_abbrev_with_validation",
|
||||
new_callable=AsyncMock,
|
||||
return_value=other_team,
|
||||
),
|
||||
patch("commands.transactions.trade.league_service") as mock_league,
|
||||
):
|
||||
mock_league.get_current_state = AsyncMock(return_value=None)
|
||||
|
||||
from commands.transactions.trade import TradeCommands
|
||||
|
||||
bot = MagicMock()
|
||||
cog = TradeCommands(bot)
|
||||
await cog.trade_initiate.callback(cog, mock_interaction, "NY")
|
||||
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
call_kwargs = mock_interaction.followup.send.call_args
|
||||
msg = (
|
||||
call_kwargs[0][0]
|
||||
if call_kwargs[0]
|
||||
else call_kwargs[1].get("content", "")
|
||||
)
|
||||
assert "could not retrieve league state" in msg.lower()
|
||||
@ -3,6 +3,7 @@ Tests for SBA data models
|
||||
|
||||
Validates model creation, validation, and business logic.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from models import Team, Player, Current, DraftPick, DraftData, DraftList
|
||||
@ -10,94 +11,102 @@ from models import Team, Player, Current, DraftPick, DraftData, DraftList
|
||||
|
||||
class TestSBABaseModel:
|
||||
"""Test base model functionality."""
|
||||
|
||||
|
||||
def test_model_creation_with_api_data(self):
|
||||
"""Test creating models from API data."""
|
||||
team_data = {
|
||||
'id': 1,
|
||||
'abbrev': 'NYY',
|
||||
'sname': 'Yankees',
|
||||
'lname': 'New York Yankees',
|
||||
'season': 12
|
||||
"id": 1,
|
||||
"abbrev": "NYY",
|
||||
"sname": "Yankees",
|
||||
"lname": "New York Yankees",
|
||||
"season": 12,
|
||||
}
|
||||
|
||||
|
||||
team = Team.from_api_data(team_data)
|
||||
assert team.id == 1
|
||||
assert team.abbrev == 'NYY'
|
||||
assert team.lname == 'New York Yankees'
|
||||
|
||||
assert team.abbrev == "NYY"
|
||||
assert team.lname == "New York Yankees"
|
||||
|
||||
def test_to_dict_functionality(self):
|
||||
"""Test model to dictionary conversion."""
|
||||
team = Team(id=1, abbrev='LAA', sname='Angels', lname='Los Angeles Angels', season=12)
|
||||
|
||||
team = Team(
|
||||
id=1, abbrev="LAA", sname="Angels", lname="Los Angeles Angels", season=12
|
||||
)
|
||||
|
||||
team_dict = team.to_dict()
|
||||
assert 'abbrev' in team_dict
|
||||
assert team_dict['abbrev'] == 'LAA'
|
||||
assert team_dict['lname'] == 'Los Angeles Angels'
|
||||
|
||||
assert "abbrev" in team_dict
|
||||
assert team_dict["abbrev"] == "LAA"
|
||||
assert team_dict["lname"] == "Los Angeles Angels"
|
||||
|
||||
def test_model_repr(self):
|
||||
"""Test model string representation."""
|
||||
team = Team(id=2, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12)
|
||||
team = Team(
|
||||
id=2, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=12
|
||||
)
|
||||
repr_str = repr(team)
|
||||
assert 'Team(' in repr_str
|
||||
assert 'abbrev=BOS' in repr_str
|
||||
assert "Team(" in repr_str
|
||||
assert "abbrev=BOS" in repr_str
|
||||
|
||||
|
||||
class TestTeamModel:
|
||||
"""Test Team model functionality."""
|
||||
|
||||
|
||||
def test_team_creation_minimal(self):
|
||||
"""Test team creation with minimal required fields."""
|
||||
team = Team(
|
||||
id=4,
|
||||
abbrev='HOU',
|
||||
sname='Astros',
|
||||
lname='Houston Astros',
|
||||
season=12
|
||||
id=4, abbrev="HOU", sname="Astros", lname="Houston Astros", season=12
|
||||
)
|
||||
|
||||
assert team.abbrev == 'HOU'
|
||||
assert team.sname == 'Astros'
|
||||
assert team.lname == 'Houston Astros'
|
||||
|
||||
assert team.abbrev == "HOU"
|
||||
assert team.sname == "Astros"
|
||||
assert team.lname == "Houston Astros"
|
||||
assert team.season == 12
|
||||
|
||||
|
||||
def test_team_creation_with_optional_fields(self):
|
||||
"""Test team creation with optional fields."""
|
||||
team = Team(
|
||||
id=5,
|
||||
abbrev='SF',
|
||||
sname='Giants',
|
||||
lname='San Francisco Giants',
|
||||
abbrev="SF",
|
||||
sname="Giants",
|
||||
lname="San Francisco Giants",
|
||||
season=12,
|
||||
gmid=100,
|
||||
division_id=1,
|
||||
stadium='Oracle Park',
|
||||
color='FF8C00'
|
||||
stadium="Oracle Park",
|
||||
color="FF8C00",
|
||||
)
|
||||
|
||||
|
||||
assert team.gmid == 100
|
||||
assert team.division_id == 1
|
||||
assert team.stadium == 'Oracle Park'
|
||||
assert team.color == 'FF8C00'
|
||||
|
||||
assert team.stadium == "Oracle Park"
|
||||
assert team.color == "FF8C00"
|
||||
|
||||
def test_team_str_representation(self):
|
||||
"""Test team string representation."""
|
||||
team = Team(id=3, abbrev='SD', sname='Padres', lname='San Diego Padres', season=12)
|
||||
assert str(team) == 'SD - San Diego Padres'
|
||||
team = Team(
|
||||
id=3, abbrev="SD", sname="Padres", lname="San Diego Padres", season=12
|
||||
)
|
||||
assert str(team) == "SD - San Diego Padres"
|
||||
|
||||
def test_team_roster_type_major_league(self):
|
||||
"""Test roster type detection for Major League teams."""
|
||||
from models.team import RosterType
|
||||
|
||||
# 3 chars or less → Major League
|
||||
team = Team(id=1, abbrev='NYY', sname='Yankees', lname='New York Yankees', season=12)
|
||||
team = Team(
|
||||
id=1, abbrev="NYY", sname="Yankees", lname="New York Yankees", season=12
|
||||
)
|
||||
assert team.roster_type() == RosterType.MAJOR_LEAGUE
|
||||
|
||||
team = Team(id=2, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12)
|
||||
team = Team(
|
||||
id=2, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=12
|
||||
)
|
||||
assert team.roster_type() == RosterType.MAJOR_LEAGUE
|
||||
|
||||
# Even "BHM" (ends in M) should be Major League
|
||||
team = Team(id=3, abbrev='BHM', sname='Iron', lname='Birmingham Iron', season=12)
|
||||
team = Team(
|
||||
id=3, abbrev="BHM", sname="Iron", lname="Birmingham Iron", season=12
|
||||
)
|
||||
assert team.roster_type() == RosterType.MAJOR_LEAGUE
|
||||
|
||||
def test_team_roster_type_minor_league(self):
|
||||
@ -105,14 +114,28 @@ class TestTeamModel:
|
||||
from models.team import RosterType
|
||||
|
||||
# Standard Minor League: [Team] + "MIL"
|
||||
team = Team(id=4, abbrev='NYYMIL', sname='RailRiders', lname='Staten Island RailRiders', season=12)
|
||||
team = Team(
|
||||
id=4,
|
||||
abbrev="NYYMIL",
|
||||
sname="RailRiders",
|
||||
lname="Staten Island RailRiders",
|
||||
season=12,
|
||||
)
|
||||
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
||||
|
||||
team = Team(id=5, abbrev='PORMIL', sname='Portland MiL', lname='Portland Minor League', season=12)
|
||||
team = Team(
|
||||
id=5,
|
||||
abbrev="PORMIL",
|
||||
sname="Portland MiL",
|
||||
lname="Portland Minor League",
|
||||
season=12,
|
||||
)
|
||||
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
||||
|
||||
# Case insensitive
|
||||
team = Team(id=6, abbrev='LAAmil', sname='Bees', lname='Salt Lake Bees', season=12)
|
||||
team = Team(
|
||||
id=6, abbrev="LAAmil", sname="Bees", lname="Salt Lake Bees", season=12
|
||||
)
|
||||
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
||||
|
||||
def test_team_roster_type_injured_list(self):
|
||||
@ -120,14 +143,32 @@ class TestTeamModel:
|
||||
from models.team import RosterType
|
||||
|
||||
# Standard Injured List: [Team] + "IL"
|
||||
team = Team(id=7, abbrev='NYYIL', sname='Yankees IL', lname='New York Yankees IL', season=12)
|
||||
team = Team(
|
||||
id=7,
|
||||
abbrev="NYYIL",
|
||||
sname="Yankees IL",
|
||||
lname="New York Yankees IL",
|
||||
season=12,
|
||||
)
|
||||
assert team.roster_type() == RosterType.INJURED_LIST
|
||||
|
||||
team = Team(id=8, abbrev='PORIL', sname='Loggers IL', lname='Portland Loggers IL', season=12)
|
||||
team = Team(
|
||||
id=8,
|
||||
abbrev="PORIL",
|
||||
sname="Loggers IL",
|
||||
lname="Portland Loggers IL",
|
||||
season=12,
|
||||
)
|
||||
assert team.roster_type() == RosterType.INJURED_LIST
|
||||
|
||||
# Case insensitive
|
||||
team = Team(id=9, abbrev='LAAil', sname='Angels IL', lname='Los Angeles Angels IL', season=12)
|
||||
team = Team(
|
||||
id=9,
|
||||
abbrev="LAAil",
|
||||
sname="Angels IL",
|
||||
lname="Los Angeles Angels IL",
|
||||
season=12,
|
||||
)
|
||||
assert team.roster_type() == RosterType.INJURED_LIST
|
||||
|
||||
def test_team_roster_type_edge_case_bhmil(self):
|
||||
@ -143,16 +184,30 @@ class TestTeamModel:
|
||||
from models.team import RosterType
|
||||
|
||||
# "BHMIL" = "BHM" + "IL" → sname contains "IL" → INJURED_LIST
|
||||
team = Team(id=10, abbrev='BHMIL', sname='Iron IL', lname='Birmingham Iron IL', season=12)
|
||||
team = Team(
|
||||
id=10,
|
||||
abbrev="BHMIL",
|
||||
sname="Iron IL",
|
||||
lname="Birmingham Iron IL",
|
||||
season=12,
|
||||
)
|
||||
assert team.roster_type() == RosterType.INJURED_LIST
|
||||
|
||||
# Compare with a real Minor League team that has "Island" in name
|
||||
# "NYYMIL" = "NYY" + "MIL", even though sname has "Island" → MINOR_LEAGUE
|
||||
team = Team(id=11, abbrev='NYYMIL', sname='Staten Island RailRiders', lname='Staten Island RailRiders', season=12)
|
||||
team = Team(
|
||||
id=11,
|
||||
abbrev="NYYMIL",
|
||||
sname="Staten Island RailRiders",
|
||||
lname="Staten Island RailRiders",
|
||||
season=12,
|
||||
)
|
||||
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
||||
|
||||
# Another IL edge case with sname containing "IL" at word boundary
|
||||
team = Team(id=12, abbrev='WVMIL', sname='WV IL', lname='West Virginia IL', season=12)
|
||||
team = Team(
|
||||
id=12, abbrev="WVMIL", sname="WV IL", lname="West Virginia IL", season=12
|
||||
)
|
||||
assert team.roster_type() == RosterType.INJURED_LIST
|
||||
|
||||
def test_team_roster_type_sname_disambiguation(self):
|
||||
@ -160,221 +215,231 @@ class TestTeamModel:
|
||||
from models.team import RosterType
|
||||
|
||||
# MiL team - sname does NOT have "IL" as a word
|
||||
team = Team(id=13, abbrev='WVMIL', sname='Miners', lname='West Virginia Miners', season=12)
|
||||
team = Team(
|
||||
id=13,
|
||||
abbrev="WVMIL",
|
||||
sname="Miners",
|
||||
lname="West Virginia Miners",
|
||||
season=12,
|
||||
)
|
||||
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
||||
|
||||
# IL team - sname has "IL" at word boundary
|
||||
team = Team(id=14, abbrev='WVMIL', sname='Miners IL', lname='West Virginia Miners IL', season=12)
|
||||
team = Team(
|
||||
id=14,
|
||||
abbrev="WVMIL",
|
||||
sname="Miners IL",
|
||||
lname="West Virginia Miners IL",
|
||||
season=12,
|
||||
)
|
||||
assert team.roster_type() == RosterType.INJURED_LIST
|
||||
|
||||
# MiL team - sname has "IL" but only in "Island" (substring, not word boundary)
|
||||
team = Team(id=15, abbrev='CHIMIL', sname='Island Hoppers', lname='Chicago Island Hoppers', season=12)
|
||||
team = Team(
|
||||
id=15,
|
||||
abbrev="CHIMIL",
|
||||
sname="Island Hoppers",
|
||||
lname="Chicago Island Hoppers",
|
||||
season=12,
|
||||
)
|
||||
assert team.roster_type() == RosterType.MINOR_LEAGUE
|
||||
|
||||
|
||||
class TestPlayerModel:
|
||||
"""Test Player model functionality."""
|
||||
|
||||
|
||||
def test_player_creation(self):
|
||||
"""Test player creation with required fields."""
|
||||
player = Player(
|
||||
id=101,
|
||||
name='Mike Trout',
|
||||
name="Mike Trout",
|
||||
wara=8.5,
|
||||
season=12,
|
||||
team_id=1,
|
||||
image='trout.jpg',
|
||||
pos_1='CF'
|
||||
image="trout.jpg",
|
||||
pos_1="CF",
|
||||
)
|
||||
|
||||
assert player.name == 'Mike Trout'
|
||||
|
||||
assert player.name == "Mike Trout"
|
||||
assert player.wara == 8.5
|
||||
assert player.team_id == 1
|
||||
assert player.pos_1 == 'CF'
|
||||
|
||||
assert player.pos_1 == "CF"
|
||||
|
||||
def test_player_positions_property(self):
|
||||
"""Test player positions property."""
|
||||
player = Player(
|
||||
id=102,
|
||||
name='Shohei Ohtani',
|
||||
name="Shohei Ohtani",
|
||||
wara=9.0,
|
||||
season=12,
|
||||
team_id=1,
|
||||
image='ohtani.jpg',
|
||||
pos_1='SP',
|
||||
pos_2='DH',
|
||||
pos_3='RF'
|
||||
image="ohtani.jpg",
|
||||
pos_1="SP",
|
||||
pos_2="DH",
|
||||
pos_3="RF",
|
||||
)
|
||||
|
||||
|
||||
positions = player.positions
|
||||
assert len(positions) == 3
|
||||
assert 'SP' in positions
|
||||
assert 'DH' in positions
|
||||
assert 'RF' in positions
|
||||
|
||||
assert "SP" in positions
|
||||
assert "DH" in positions
|
||||
assert "RF" in positions
|
||||
|
||||
def test_player_primary_position(self):
|
||||
"""Test primary position property."""
|
||||
player = Player(
|
||||
id=103,
|
||||
name='Mookie Betts',
|
||||
name="Mookie Betts",
|
||||
wara=7.2,
|
||||
season=12,
|
||||
team_id=1,
|
||||
image='betts.jpg',
|
||||
pos_1='RF',
|
||||
pos_2='2B'
|
||||
image="betts.jpg",
|
||||
pos_1="RF",
|
||||
pos_2="2B",
|
||||
)
|
||||
|
||||
assert player.primary_position == 'RF'
|
||||
|
||||
|
||||
assert player.primary_position == "RF"
|
||||
|
||||
def test_player_is_pitcher(self):
|
||||
"""Test is_pitcher property."""
|
||||
pitcher = Player(
|
||||
id=104,
|
||||
name='Gerrit Cole',
|
||||
name="Gerrit Cole",
|
||||
wara=6.8,
|
||||
season=12,
|
||||
team_id=1,
|
||||
image='cole.jpg',
|
||||
pos_1='SP'
|
||||
image="cole.jpg",
|
||||
pos_1="SP",
|
||||
)
|
||||
|
||||
|
||||
position_player = Player(
|
||||
id=105,
|
||||
name='Aaron Judge',
|
||||
name="Aaron Judge",
|
||||
wara=8.1,
|
||||
season=12,
|
||||
team_id=1,
|
||||
image='judge.jpg',
|
||||
pos_1='RF'
|
||||
image="judge.jpg",
|
||||
pos_1="RF",
|
||||
)
|
||||
|
||||
|
||||
assert pitcher.is_pitcher is True
|
||||
assert position_player.is_pitcher is False
|
||||
|
||||
|
||||
def test_player_str_representation(self):
|
||||
"""Test player string representation."""
|
||||
player = Player(
|
||||
id=106,
|
||||
name='Ronald Acuna Jr.',
|
||||
name="Ronald Acuna Jr.",
|
||||
wara=8.8,
|
||||
season=12,
|
||||
team_id=1,
|
||||
image='acuna.jpg',
|
||||
pos_1='OF'
|
||||
image="acuna.jpg",
|
||||
pos_1="OF",
|
||||
)
|
||||
|
||||
assert str(player) == 'Ronald Acuna Jr. (OF)'
|
||||
|
||||
assert str(player) == "Ronald Acuna Jr. (OF)"
|
||||
|
||||
|
||||
class TestCurrentModel:
|
||||
"""Test Current league state model."""
|
||||
|
||||
|
||||
def test_current_default_values(self):
|
||||
"""Test current model with default values."""
|
||||
current = Current()
|
||||
|
||||
|
||||
assert current.week == 69
|
||||
assert current.season == 69
|
||||
assert current.freeze is True
|
||||
assert current.bet_week == 'sheets'
|
||||
|
||||
assert current.bet_week == "sheets"
|
||||
|
||||
def test_current_with_custom_values(self):
|
||||
"""Test current model with custom values."""
|
||||
current = Current(
|
||||
week=15,
|
||||
season=12,
|
||||
freeze=False,
|
||||
trade_deadline=14,
|
||||
playoffs_begin=19
|
||||
week=15, season=12, freeze=False, trade_deadline=14, playoffs_begin=19
|
||||
)
|
||||
|
||||
|
||||
assert current.week == 15
|
||||
assert current.season == 12
|
||||
assert current.freeze is False
|
||||
|
||||
|
||||
def test_current_properties(self):
|
||||
"""Test current model properties."""
|
||||
# Regular season
|
||||
current = Current(week=10, playoffs_begin=19)
|
||||
assert current.is_offseason is False
|
||||
assert current.is_playoffs is False
|
||||
|
||||
|
||||
# Playoffs
|
||||
current = Current(week=20, playoffs_begin=19)
|
||||
assert current.is_offseason is True
|
||||
assert current.is_playoffs is True
|
||||
|
||||
|
||||
# Pick trading
|
||||
current = Current(week=15, pick_trade_start=10, pick_trade_end=20)
|
||||
assert current.can_trade_picks is True
|
||||
|
||||
def test_is_past_trade_deadline(self):
|
||||
"""Test trade deadline property — trades allowed during deadline week, blocked after."""
|
||||
# Before deadline
|
||||
current = Current(week=10, trade_deadline=14)
|
||||
assert current.is_past_trade_deadline is False
|
||||
|
||||
# At deadline week (still allowed)
|
||||
current = Current(week=14, trade_deadline=14)
|
||||
assert current.is_past_trade_deadline is False
|
||||
|
||||
# One week past deadline
|
||||
current = Current(week=15, trade_deadline=14)
|
||||
assert current.is_past_trade_deadline is True
|
||||
|
||||
# Offseason bypasses deadline (week > 18)
|
||||
current = Current(week=20, trade_deadline=14)
|
||||
assert current.is_offseason is True
|
||||
assert current.is_past_trade_deadline is False
|
||||
|
||||
|
||||
class TestDraftPickModel:
|
||||
"""Test DraftPick model functionality."""
|
||||
|
||||
|
||||
def test_draft_pick_creation(self):
|
||||
"""Test draft pick creation."""
|
||||
pick = DraftPick(
|
||||
season=12,
|
||||
overall=1,
|
||||
round=1,
|
||||
origowner_id=1,
|
||||
owner_id=1
|
||||
)
|
||||
|
||||
pick = DraftPick(season=12, overall=1, round=1, origowner_id=1, owner_id=1)
|
||||
|
||||
assert pick.season == 12
|
||||
assert pick.overall == 1
|
||||
assert pick.origowner_id == 1
|
||||
assert pick.owner_id == 1
|
||||
|
||||
|
||||
def test_draft_pick_properties(self):
|
||||
"""Test draft pick properties."""
|
||||
# Not traded, not selected
|
||||
pick = DraftPick(
|
||||
season=12,
|
||||
overall=5,
|
||||
round=1,
|
||||
origowner_id=1,
|
||||
owner_id=1
|
||||
)
|
||||
|
||||
pick = DraftPick(season=12, overall=5, round=1, origowner_id=1, owner_id=1)
|
||||
|
||||
assert pick.is_traded is False
|
||||
assert pick.is_selected is False
|
||||
|
||||
|
||||
# Traded pick
|
||||
traded_pick = DraftPick(
|
||||
season=12,
|
||||
overall=10,
|
||||
round=1,
|
||||
origowner_id=1,
|
||||
owner_id=2
|
||||
season=12, overall=10, round=1, origowner_id=1, owner_id=2
|
||||
)
|
||||
|
||||
|
||||
assert traded_pick.is_traded is True
|
||||
|
||||
|
||||
# Selected pick
|
||||
selected_pick = DraftPick(
|
||||
season=12,
|
||||
overall=15,
|
||||
round=1,
|
||||
origowner_id=1,
|
||||
owner_id=1,
|
||||
player_id=100
|
||||
season=12, overall=15, round=1, origowner_id=1, owner_id=1, player_id=100
|
||||
)
|
||||
|
||||
|
||||
assert selected_pick.is_selected is True
|
||||
|
||||
|
||||
class TestDraftDataModel:
|
||||
"""Test DraftData model functionality."""
|
||||
|
||||
|
||||
def test_draft_data_creation(self):
|
||||
"""Test draft data creation."""
|
||||
draft_data = DraftData(
|
||||
result_channel=123456789,
|
||||
ping_channel=987654321,
|
||||
pick_minutes=10
|
||||
result_channel=123456789, ping_channel=987654321, pick_minutes=10
|
||||
)
|
||||
|
||||
assert draft_data.result_channel == 123456789
|
||||
@ -384,20 +449,12 @@ class TestDraftDataModel:
|
||||
def test_draft_data_properties(self):
|
||||
"""Test draft data properties."""
|
||||
# Inactive draft
|
||||
draft_data = DraftData(
|
||||
result_channel=123,
|
||||
ping_channel=456,
|
||||
timer=False
|
||||
)
|
||||
draft_data = DraftData(result_channel=123, ping_channel=456, timer=False)
|
||||
|
||||
assert draft_data.is_draft_active is False
|
||||
|
||||
# Active draft
|
||||
active_draft = DraftData(
|
||||
result_channel=123,
|
||||
ping_channel=456,
|
||||
timer=True
|
||||
)
|
||||
active_draft = DraftData(result_channel=123, ping_channel=456, timer=True)
|
||||
|
||||
assert active_draft.is_draft_active is True
|
||||
|
||||
@ -409,17 +466,13 @@ class TestDraftListModel:
|
||||
not just IDs. The API returns these objects populated.
|
||||
"""
|
||||
|
||||
def _create_mock_team(self, team_id: int = 1) -> 'Team':
|
||||
def _create_mock_team(self, team_id: int = 1) -> "Team":
|
||||
"""Create a mock team for testing."""
|
||||
return Team(
|
||||
id=team_id,
|
||||
abbrev="TST",
|
||||
sname="Test",
|
||||
lname="Test Team",
|
||||
season=12
|
||||
id=team_id, abbrev="TST", sname="Test", lname="Test Team", season=12
|
||||
)
|
||||
|
||||
def _create_mock_player(self, player_id: int = 100) -> 'Player':
|
||||
def _create_mock_player(self, player_id: int = 100) -> "Player":
|
||||
"""Create a mock player for testing."""
|
||||
return Player(
|
||||
id=player_id,
|
||||
@ -430,7 +483,7 @@ class TestDraftListModel:
|
||||
team_id=1,
|
||||
season=12,
|
||||
wara=2.5,
|
||||
image="https://example.com/test.jpg"
|
||||
image="https://example.com/test.jpg",
|
||||
)
|
||||
|
||||
def test_draft_list_creation(self):
|
||||
@ -438,12 +491,7 @@ class TestDraftListModel:
|
||||
mock_team = self._create_mock_team(team_id=1)
|
||||
mock_player = self._create_mock_player(player_id=100)
|
||||
|
||||
draft_entry = DraftList(
|
||||
season=12,
|
||||
team=mock_team,
|
||||
rank=1,
|
||||
player=mock_player
|
||||
)
|
||||
draft_entry = DraftList(season=12, team=mock_team, rank=1, player=mock_player)
|
||||
|
||||
assert draft_entry.season == 12
|
||||
assert draft_entry.team_id == 1
|
||||
@ -456,18 +504,10 @@ class TestDraftListModel:
|
||||
mock_player_top = self._create_mock_player(player_id=100)
|
||||
mock_player_lower = self._create_mock_player(player_id=200)
|
||||
|
||||
top_pick = DraftList(
|
||||
season=12,
|
||||
team=mock_team,
|
||||
rank=1,
|
||||
player=mock_player_top
|
||||
)
|
||||
top_pick = DraftList(season=12, team=mock_team, rank=1, player=mock_player_top)
|
||||
|
||||
lower_pick = DraftList(
|
||||
season=12,
|
||||
team=mock_team,
|
||||
rank=5,
|
||||
player=mock_player_lower
|
||||
season=12, team=mock_team, rank=5, player=mock_player_lower
|
||||
)
|
||||
|
||||
assert top_pick.is_top_ranked is True
|
||||
@ -486,32 +526,32 @@ class TestDraftListModel:
|
||||
"""
|
||||
# Simulate API response format - nested objects, NOT flat IDs
|
||||
api_response = {
|
||||
'id': 303,
|
||||
'season': 13,
|
||||
'rank': 1,
|
||||
'team': {
|
||||
'id': 548,
|
||||
'abbrev': 'WV',
|
||||
'sname': 'Black Bears',
|
||||
'lname': 'West Virginia Black Bears',
|
||||
'season': 13
|
||||
"id": 303,
|
||||
"season": 13,
|
||||
"rank": 1,
|
||||
"team": {
|
||||
"id": 548,
|
||||
"abbrev": "WV",
|
||||
"sname": "Black Bears",
|
||||
"lname": "West Virginia Black Bears",
|
||||
"season": 13,
|
||||
},
|
||||
'player': {
|
||||
'id': 12843,
|
||||
'name': 'George Springer',
|
||||
'wara': 0.31,
|
||||
'image': 'https://example.com/springer.png',
|
||||
'season': 13,
|
||||
'pos_1': 'CF',
|
||||
"player": {
|
||||
"id": 12843,
|
||||
"name": "George Springer",
|
||||
"wara": 0.31,
|
||||
"image": "https://example.com/springer.png",
|
||||
"season": 13,
|
||||
"pos_1": "CF",
|
||||
# Note: NO flat team_id here - it's nested in 'team' below
|
||||
'team': {
|
||||
'id': 547, # Free Agent team
|
||||
'abbrev': 'FA',
|
||||
'sname': 'Free Agents',
|
||||
'lname': 'Free Agents',
|
||||
'season': 13
|
||||
}
|
||||
}
|
||||
"team": {
|
||||
"id": 547, # Free Agent team
|
||||
"abbrev": "FA",
|
||||
"sname": "Free Agents",
|
||||
"lname": "Free Agents",
|
||||
"season": 13,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Create DraftList using from_api_data (what BaseService calls)
|
||||
@ -522,87 +562,94 @@ class TestDraftListModel:
|
||||
assert draft_entry.player is not None
|
||||
|
||||
# CRITICAL: player.team_id must be extracted from nested team object
|
||||
assert draft_entry.player.team_id == 547, \
|
||||
assert draft_entry.player.team_id == 547, (
|
||||
f"player.team_id should be 547 (FA), got {draft_entry.player.team_id}"
|
||||
)
|
||||
|
||||
# Verify the nested team object is also populated
|
||||
assert draft_entry.player.team is not None
|
||||
assert draft_entry.player.team.id == 547
|
||||
assert draft_entry.player.team.abbrev == 'FA'
|
||||
assert draft_entry.player.team.abbrev == "FA"
|
||||
|
||||
# Verify DraftList's own team data
|
||||
assert draft_entry.team.id == 548
|
||||
assert draft_entry.team.abbrev == 'WV'
|
||||
assert draft_entry.team.abbrev == "WV"
|
||||
assert draft_entry.team_id == 548 # Property from nested team
|
||||
|
||||
|
||||
class TestModelCoverageExtras:
|
||||
"""Additional model coverage tests."""
|
||||
|
||||
|
||||
def test_base_model_from_api_data_validation(self):
|
||||
"""Test from_api_data with various edge cases."""
|
||||
from models.base import SBABaseModel
|
||||
|
||||
|
||||
# Test with empty data raises ValueError
|
||||
with pytest.raises(ValueError, match="Cannot create SBABaseModel from empty data"):
|
||||
with pytest.raises(
|
||||
ValueError, match="Cannot create SBABaseModel from empty data"
|
||||
):
|
||||
SBABaseModel.from_api_data({})
|
||||
|
||||
|
||||
# Test with None raises ValueError
|
||||
with pytest.raises(ValueError, match="Cannot create SBABaseModel from empty data"):
|
||||
with pytest.raises(
|
||||
ValueError, match="Cannot create SBABaseModel from empty data"
|
||||
):
|
||||
SBABaseModel.from_api_data(None)
|
||||
|
||||
|
||||
def test_player_positions_comprehensive(self):
|
||||
"""Test player positions property with all position variations."""
|
||||
player_data = {
|
||||
'id': 201,
|
||||
'name': 'Multi-Position Player',
|
||||
'wara': 3.0,
|
||||
'season': 12,
|
||||
'team_id': 5,
|
||||
'image': 'https://example.com/player.jpg',
|
||||
'pos_1': 'C',
|
||||
'pos_2': '1B',
|
||||
'pos_3': '3B',
|
||||
'pos_4': None, # Test None handling
|
||||
'pos_5': 'DH',
|
||||
'pos_6': 'OF',
|
||||
'pos_7': None, # Another None
|
||||
'pos_8': 'SS'
|
||||
"id": 201,
|
||||
"name": "Multi-Position Player",
|
||||
"wara": 3.0,
|
||||
"season": 12,
|
||||
"team_id": 5,
|
||||
"image": "https://example.com/player.jpg",
|
||||
"pos_1": "C",
|
||||
"pos_2": "1B",
|
||||
"pos_3": "3B",
|
||||
"pos_4": None, # Test None handling
|
||||
"pos_5": "DH",
|
||||
"pos_6": "OF",
|
||||
"pos_7": None, # Another None
|
||||
"pos_8": "SS",
|
||||
}
|
||||
player = Player.from_api_data(player_data)
|
||||
|
||||
|
||||
positions = player.positions
|
||||
assert 'C' in positions
|
||||
assert '1B' in positions
|
||||
assert '3B' in positions
|
||||
assert 'DH' in positions
|
||||
assert 'OF' in positions
|
||||
assert 'SS' in positions
|
||||
assert "C" in positions
|
||||
assert "1B" in positions
|
||||
assert "3B" in positions
|
||||
assert "DH" in positions
|
||||
assert "OF" in positions
|
||||
assert "SS" in positions
|
||||
assert len(positions) == 6 # Should exclude None values
|
||||
assert None not in positions
|
||||
|
||||
|
||||
def test_player_is_pitcher_variations(self):
|
||||
"""Test is_pitcher property with different positions."""
|
||||
test_cases = [
|
||||
('SP', True), # Starting pitcher
|
||||
('RP', True), # Relief pitcher
|
||||
('P', True), # Generic pitcher
|
||||
('C', False), # Catcher
|
||||
('1B', False), # First base
|
||||
('OF', False), # Outfield
|
||||
('DH', False), # Designated hitter
|
||||
("SP", True), # Starting pitcher
|
||||
("RP", True), # Relief pitcher
|
||||
("P", True), # Generic pitcher
|
||||
("C", False), # Catcher
|
||||
("1B", False), # First base
|
||||
("OF", False), # Outfield
|
||||
("DH", False), # Designated hitter
|
||||
]
|
||||
|
||||
|
||||
for position, expected in test_cases:
|
||||
player_data = {
|
||||
'id': 300 + ord(position[0]), # Generate unique IDs based on position
|
||||
'name': f'Test {position}',
|
||||
'wara': 2.0,
|
||||
'season': 12,
|
||||
'team_id': 5,
|
||||
'image': 'https://example.com/player.jpg',
|
||||
'pos_1': position,
|
||||
"id": 300 + ord(position[0]), # Generate unique IDs based on position
|
||||
"name": f"Test {position}",
|
||||
"wara": 2.0,
|
||||
"season": 12,
|
||||
"team_id": 5,
|
||||
"image": "https://example.com/player.jpg",
|
||||
"pos_1": position,
|
||||
}
|
||||
player = Player.from_api_data(player_data)
|
||||
assert player.is_pitcher == expected, f"Position {position} should return {expected}"
|
||||
assert player.primary_position == position
|
||||
assert player.is_pitcher == expected, (
|
||||
f"Position {position} should return {expected}"
|
||||
)
|
||||
assert player.primary_position == position
|
||||
|
||||
@ -24,7 +24,8 @@ from utils.scorebug_helpers import create_scorebug_embed, create_team_progress_b
|
||||
class TestScorecardTrackerFreshReads:
|
||||
"""Tests that ScorecardTracker reads fresh data from disk (fix for #40)."""
|
||||
|
||||
def test_get_all_scorecards_reads_fresh_data(self, tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_scorecards_reads_fresh_data(self, tmp_path):
|
||||
"""get_all_scorecards() should pick up scorecards written by another process.
|
||||
|
||||
Simulates the background task having a stale tracker instance while
|
||||
@ -34,7 +35,7 @@ class TestScorecardTrackerFreshReads:
|
||||
data_file.write_text(json.dumps({"scorecards": {}}))
|
||||
|
||||
tracker = ScorecardTracker(data_file=str(data_file))
|
||||
assert tracker.get_all_scorecards() == []
|
||||
assert await tracker.get_all_scorecards() == []
|
||||
|
||||
# Another process writes a scorecard to the same file
|
||||
new_data = {
|
||||
@ -51,17 +52,18 @@ class TestScorecardTrackerFreshReads:
|
||||
data_file.write_text(json.dumps(new_data))
|
||||
|
||||
# Should see the new scorecard without restart
|
||||
result = tracker.get_all_scorecards()
|
||||
result = await tracker.get_all_scorecards()
|
||||
assert len(result) == 1
|
||||
assert result[0] == (111, "https://docs.google.com/spreadsheets/d/abc123")
|
||||
|
||||
def test_get_scorecard_reads_fresh_data(self, tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_scorecard_reads_fresh_data(self, tmp_path):
|
||||
"""get_scorecard() should pick up a scorecard written by another process."""
|
||||
data_file = tmp_path / "scorecards.json"
|
||||
data_file.write_text(json.dumps({"scorecards": {}}))
|
||||
|
||||
tracker = ScorecardTracker(data_file=str(data_file))
|
||||
assert tracker.get_scorecard(222) is None
|
||||
assert await tracker.get_scorecard(222) is None
|
||||
|
||||
# Another process writes a scorecard
|
||||
new_data = {
|
||||
@ -79,7 +81,7 @@ class TestScorecardTrackerFreshReads:
|
||||
|
||||
# Should see the new scorecard
|
||||
assert (
|
||||
tracker.get_scorecard(222)
|
||||
await tracker.get_scorecard(222)
|
||||
== "https://docs.google.com/spreadsheets/d/xyz789"
|
||||
)
|
||||
|
||||
|
||||
@ -124,6 +124,22 @@ class TradeEmbedView(discord.ui.View):
|
||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||
):
|
||||
"""Handle submit trade button click."""
|
||||
# Check trade deadline
|
||||
current = await league_service.get_current_state()
|
||||
if not current:
|
||||
await interaction.response.send_message(
|
||||
"❌ Could not retrieve league state. Please try again later.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
if current.is_past_trade_deadline:
|
||||
await interaction.response.send_message(
|
||||
f"❌ **The trade deadline has passed** (Week {current.trade_deadline}). "
|
||||
f"This trade can no longer be submitted.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
if self.builder.is_empty:
|
||||
await interaction.response.send_message(
|
||||
"Cannot submit empty trade. Add some moves first!", ephemeral=True
|
||||
@ -433,7 +449,16 @@ class TradeAcceptanceView(discord.ui.View):
|
||||
config = get_config()
|
||||
|
||||
current = await league_service.get_current_state()
|
||||
next_week = current.week + 1 if current else 1
|
||||
if not current or current.is_past_trade_deadline:
|
||||
deadline_msg = (
|
||||
f"❌ **The trade deadline has passed** (Week {current.trade_deadline}). "
|
||||
f"This trade cannot be finalized."
|
||||
if current
|
||||
else "❌ Could not retrieve league state. Please try again later."
|
||||
)
|
||||
await interaction.followup.send(deadline_msg, ephemeral=True)
|
||||
return
|
||||
next_week = current.week + 1
|
||||
|
||||
fa_team = Team(
|
||||
id=config.free_agent_team_id,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user