diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 47d6404..f702307 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,9 +13,11 @@ "Bash(npm run typecheck:*)", "Bash(npm run lint:*)", "Bash(npm run:*)", - "WebFetch(domain:zipline.diced.sh)" + "WebFetch(domain:zipline.diced.sh)", + "Bash(npm test)", + "Bash(npx tsc:*)" ], "deny": [], "ask": [] } -} \ No newline at end of file +} diff --git a/.eslintignore b/.eslintignore index d0e8228..93a7163 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,3 +8,9 @@ /temp /src/db/schema.js /src/db/schema.d.ts + +# Config files +vitest.config.ts +vitest.config.js +vitest.config.d.ts +vitest.config.js.map diff --git a/.eslintrc.json b/.eslintrc.json index 3fbcda9..0c2a253 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -91,5 +91,17 @@ } ], "unicorn/prefer-node-protocol": "error" - } + }, + "overrides": [ + { + "files": ["tests/**/*.ts"], + "parserOptions": { + "project": "./tsconfig.test.json" + }, + "rules": { + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/typedef": "off" + } + } + ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6a4d92..f17f34f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,8 @@ jobs: - name: Type check run: npx tsc --noEmit + - name: Run tests + run: npm run test + - name: Build project run: npm run build diff --git a/CLAUDE.md b/CLAUDE.md index 8f78962..07e90a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,74 +4,175 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a TypeScript-based Markov chain text generation system with OpenAI integration. The project generates text using Markov chains trained on input data and can optionally use OpenAI's API for enhanced text generation and conversation handling. +This is a Discord.js bot written in TypeScript, built on the Discord Bot TypeScript Template. The bot features a fishing game with an economy system, AI-powered image generation, and OpenAI integration for enhanced interactions. ## Development Commands ### Build and Development ```bash npm run build # Compile TypeScript to JavaScript -npm run dev # Run development server with hot reload -npm start # Start the production server +npm start # Start the bot (single instance) +npm run start:manager # Start with shard manager for multiple shards +npm run start:pm2 # Start with PM2 process manager ``` ### Code Quality ```bash npm run lint # Run ESLint for code linting npm run lint:fix # Fix auto-fixable linting issues -npm run typecheck # Run TypeScript type checking +npm run format # Check code formatting with Prettier +npm run format:fix # Fix code formatting issues ``` ### Testing ```bash npm test # Run all tests +npm run test:unit # Run unit tests only +npm run test:integration # Run integration tests only npm run test:watch # Run tests in watch mode +npm run test:coverage # Run tests with coverage report ``` -## Architecture Overview - -### Core Components +### Database Management +```bash +npm run db:generate # Generate database migrations +npm run db:push # Push schema changes to database +npm run db:migrate # Run database migrations +npm run db:seed # Seed database with initial data +npm run db:setup # Push schema and seed data +npm run db:studio # Open Drizzle Studio for database management +``` -- **MarkovChain** (`src/MarkovChain.ts`): The main class implementing Markov chain text generation - - Handles state transitions and probability calculations - - Supports configurable order (n-gram size) for chain complexity - - Provides text generation with customizable parameters +### Command Management +```bash +npm run commands:view [GUILD_ID] # View registered commands +npm run commands:register [GUILD_ID] # Register commands (guild-specific if ID provided) +npm run commands:clear [GUILD_ID] # Clear registered commands +npm run commands:rename # Rename a command +npm run commands:delete # Delete a specific command +``` -- **OpenAI Integration** (`src/openai.ts`): Handles OpenAI API interactions - - Thread management for conversation context - - Response generation and processing - - Credit tracking and usage monitoring +## Architecture Overview -- **Type Definitions** (`src/types.ts`): Core TypeScript interfaces and types - - `MarkovState`: Represents chain states and transitions - - `GenerationOptions`: Configuration for text generation - - OpenAI-related types for API integration +### Core Components -- **Utilities** (`src/utils.ts`): Helper functions for data processing - - Text preprocessing and tokenization - - File I/O operations - - Data validation and sanitization +- **Bot Initialization** (`src/start-bot.ts`, `src/start-manager.ts`): Entry points for bot and shard manager + - Bot setup and configuration + - Command registration + - Event handler initialization + - Service initialization (OpenAI, Database) + +- **Commands** (`src/commands/`): Discord slash commands + - **Chat Commands**: `/fish`, `/fishing`, `/shop`, `/buy`, `/inventory`, `/generate-image`, `/help`, `/info`, `/test`, `/dev` + - **Context Menu Commands**: View date sent (message), View date joined (user) + - Command metadata and argument definitions in `metadata.ts` and `args.ts` + +- **Services** (`src/services/`): Business logic layer + - **FishingService**: Core fishing mechanics and catch logic + - **UserService**: User data management + - **GuildService**: Server settings and configuration + - **DatabaseService**: Database connection and query management (Drizzle ORM) + - **OpenAIService**: OpenAI API integration for AI features + - **ImageUpload**: Image upload to fal.ai for AI-generated images + - **ItemEffectsService**: Item effect calculations (rarity boosts, worth multipliers) + - **FishingCooldownService**: Rate limiting for fishing commands + - **ShopService**: Shop and purchase management + +- **Database Schema** (`src/db/schema.ts`): PostgreSQL schema using Drizzle ORM + - **users**: Discord user profiles with money and auto-fishing status + - **catchables**: Fish and items that can be caught (rarity, worth, images) + - **catches**: Record of user catches + - **items**: Purchasable items with effects (consumable/passive) + - **shop**: Items available for purchase + - **purchases**: Purchase history + - **inventory**: User item inventory + - **guilds**: Server-specific settings (cooldown limits) + +- **Events** (`src/events/`): Discord event handlers + - **CommandHandler**: Slash command execution + - **ButtonHandler**: Button interaction handling + - **MessageHandler**: Message event processing + - **ReactionHandler**: Reaction event processing + - **TriggerHandler**: Custom trigger system + - **GuildJoinHandler**: New server welcome messages + - **GuildLeaveHandler**: Server leave cleanup + +- **Utilities** (`src/utils/`): Helper functions + - **ClientUtils**: Discord client helpers + - **CommandUtils**: Command processing utilities + - **MessageUtils**: Message formatting and sending + - **PermissionUtils**: Permission checking + - **DbUtils**: Database query helpers + - **FormatUtils**: Data formatting + - **StringUtils**: String manipulation + - **MathUtils**: Mathematical calculations + - **RandomUtils**: Random number generation + +- **Models** (`src/models/`): Data models and type definitions + - **ConfigModels**: Configuration types + - **InternalModels**: Internal data structures + - **API Models**: REST API request/response types for cluster API + +### Key Features + +1. **Fishing Game** + - Catch various fish/items with different rarities + - Economy system with money and purchasable items + - Item effects (rarity boosts, worth multipliers) + - Auto-fishing capability + - Cooldown system to prevent spam + +2. **AI Integration** + - OpenAI API for enhanced interactions + - Image generation via fal.ai + - Thread management for conversation context + +3. **Scalability** + - Sharding support for large bot deployments (2500+ servers) + - Clustering support for multi-machine deployment + - PM2 process manager integration + - Cluster API for cross-shard communication + +4. **Developer Features** + - TypeScript with strict type checking + - ESM modules for modern JavaScript + - Comprehensive testing with Vitest + - Database migrations with Drizzle Kit + - Localization support via `lang/` directory ### Data Flow -1. Input text is processed and tokenized in utilities -2. MarkovChain builds state transition tables from processed data -3. Text generation uses probability-based state transitions -4. OpenAI integration can enhance or supplement generated content -5. Results are processed and returned to the caller +1. User invokes slash command in Discord +2. CommandHandler receives interaction and routes to appropriate Command +3. Command validates permissions and arguments +4. Command calls relevant Service(s) for business logic +5. Service queries/updates database via DatabaseService +6. Service applies game logic (e.g., fishing mechanics, item effects) +7. Results formatted and sent back to Discord user via MessageUtils ### Key Design Patterns -- **Probabilistic State Machine**: Markov chains use statistical transitions between states -- **Strategy Pattern**: Different generation strategies (pure Markov vs OpenAI-enhanced) -- **Builder Pattern**: Configurable generation options and chain parameters +- **Handler Pattern**: Event handlers process Discord events and route to appropriate components +- **Service Layer**: Business logic separated from command/event handling +- **Repository Pattern**: DatabaseService abstracts database operations +- **Factory Pattern**: Command and event handler registration +- **Singleton Pattern**: Services like OpenAIService and DatabaseService ## Configuration -- TypeScript configuration in `tsconfig.json` with strict type checking -- ESLint configuration for code quality enforcement -- Build output to `dist/` directory +- **TypeScript**: `tsconfig.json` with strict type checking, ESM modules +- **ESLint**: `.eslintrc.json` for code quality +- **Prettier**: `.prettierrc.json` for code formatting +- **Vitest**: `vitest.config.ts` for testing configuration +- **Drizzle**: `src/drizzle.config.ts` for database configuration +- **Bot Config**: `config/config.json` (see `config/*.example.json` for templates) +- **Build Output**: `dist/` directory ## Environment Variables -The application expects OpenAI API configuration through environment variables or configuration files for AI-enhanced features. \ No newline at end of file +Required environment variables (typically in `.env`): +- Discord bot token and client ID +- Database connection string (PostgreSQL) +- OpenAI API key (for AI features) +- fal.ai API key (for image generation) +- Bot developer Discord user ID(s) diff --git a/FUTURE_PLANS.md b/FUTURE_PLANS.md new file mode 100644 index 0000000..544b18a --- /dev/null +++ b/FUTURE_PLANS.md @@ -0,0 +1,2435 @@ +# Fishing Command - New Features Research + + + +**Research Date:** 2025-11-16 + +**Current Branch:** claude/research-fishing-features-01WAdMM9Ho9qhDPXRHkz8uPP + + + +## Executive Summary + + + +This document presents comprehensive research on potential new features for the fishing command system. Features are categorized by type and prioritized by impact, implementation complexity, and alignment with existing architecture. + + + +Based on analysis of successful Discord fishing bots (Virtual Fisher, Fisherman) and fishing minigames in popular RPGs, the most impactful additions would be: **Fishing Locations/Biomes**, **Quests & Achievements**, **Equipment System**, **Size Variations**, and **Seasonal Events**. + + + +--- + + + +## Current System Overview + + + +### Existing Features ✅ + +- Probabilistic rarity-based fishing (Common, Uncommon, Rare, Legendary) + +- Cooldown system (rolling window, per-guild) + +- Item effects (passive & consumable with rarity boost and worth multipliers) + +- First catch tracking + +- Currency system and economy + +- Statistics & leaderboards + +- Catch history + + + +### Architecture Strengths + +- Well-structured service layer + +- Extensible item effects system + +- Strong database foundation + +- Separate cooldown management + +- Guild context support + + + +--- + + + +## Feature Categories + + + +### 🌍 Category 1: Location & Environment Systems + + + +#### 1.1 Fishing Locations/Biomes ⭐⭐⭐⭐⭐ + +**Priority: HIGH** | **Complexity: Medium** | **Impact: Very High** + + + +**Description:** + +Different fishing locations (Ocean, Lake, River, Deep Sea, Swamp, Arctic, Tropical) each with unique fish pools and rarity distributions. + + + +**Benefits:** + +- Massively expands content variety + +- Encourages exploration and discovery + +- Natural content gating mechanism + +- Creates collection goals + + + +**Implementation Approach:** + +```typescript + +// Database schema additions + +interface Location { + + id: string; + + name: string; + + description: string; + + unlockLevel?: number; // Optional level requirement + + unlockCost?: number; // Optional money requirement + + rarityModifiers: Record; // Location-specific rarity adjustments + +} + + + +interface Catchable { + + // ... existing fields + + locationId: string; // Which location this fish appears in + + locationRarity?: Rarity; // Override rarity for specific locations + +} + +``` + + + +**User Flow:** + +1. `/fish [location]` - Fish at a specific location + +2. `/fishing locations` - View available locations and unlock status + +3. Users unlock new locations through progression (level/money) + + + +**Database Impact:** + +- New `locations` table + +- Add `locationId` column to `catchables` + +- Add `unlockedLocations` to user progress tracking + + + +**Virtual Fisher Inspiration:** Virtual Fisher has multiple biomes with unique fish pools + + + +--- + + + +#### 1.2 Weather System ⭐⭐⭐ + +**Priority: MEDIUM** | **Complexity: Medium** | **Impact: Medium** + + + +**Description:** + +Dynamic weather conditions that affect fishing outcomes (Sunny, Rainy, Stormy, Foggy, Snow). + + + +**Benefits:** + +- Adds unpredictability and timing strategy + +- Creates "weather fishing" events + +- Increases replayability + + + +**Implementation Approach:** + +```typescript + +enum Weather { + + SUNNY = 'SUNNY', // Normal conditions + + RAINY = 'RAINY', // +10% rare fish chance + + STORMY = 'STORMY', // +20% legendary chance, -50% common + + FOGGY = 'FOGGY', // Mystery fish pool + + SNOWY = 'SNOWY' // Arctic fish appear everywhere + +} + + + +// Weather changes every 4-6 hours guild-wide + +interface GuildWeather { + + guildId: string; + + currentWeather: Weather; + + nextChangeAt: Date; + +} + +``` + + + +**Integration Points:** + +- Modify `determineRarity()` based on current weather + +- Add weather display to fishing responses + +- Create weather-specific catchables + + + +--- + + + +#### 1.3 Time-Based Fishing (Day/Night Cycle) ⭐⭐⭐ + +**Priority: MEDIUM** | **Complexity: Low** | **Impact: Medium** + + + +**Description:** + +Certain fish only appear during day or night (based on server time or user timezone). + + + +**Benefits:** + +- Simple to implement + +- Encourages fishing at different times + +- Adds realism and variety + + + +**Implementation:** + +```typescript + +interface Catchable { + + // ... existing fields + + timeOfDay?: 'DAY' | 'NIGHT' | 'DAWN' | 'DUSK' | 'ANY'; + +} + + + +// Filter catchables based on current hour + +const hour = new Date().getHours(); + +const timeOfDay = hour >= 6 && hour < 18 ? 'DAY' : 'NIGHT'; + +``` + + + +--- + + + +### 🎣 Category 2: Equipment & Progression Systems + + + +#### 2.1 Fishing Rods ⭐⭐⭐⭐⭐ + +**Priority: HIGH** | **Complexity: Low-Medium** | **Impact: High** + + + +**Description:** + +Purchasable/unlockable fishing rods with different stats and bonuses. + + + +**Benefits:** + +- Clear progression path + +- Money sink for economy balance + +- Visual progression indicator + +- Easy to understand + + + +**Rod Types:** + +```typescript + +interface FishingRod extends Item { + + rarityBoostModifier: number; // Additional rarity boost percentage + + durability?: number; // Optional: rods break after X uses + + specialEffect?: RodEffect; // DOUBLE_CATCH, LUCKY_STREAK, etc. + + catchSpeedBonus?: number; // Reduced cooldown + +} + + + +// Example rods + +const rods = [ + + { name: 'Wooden Rod', cost: 0, rarityBoost: 0 }, // Default + + { name: 'Iron Rod', cost: 5000, rarityBoost: 5 }, + + { name: 'Gold Rod', cost: 25000, rarityBoost: 10 }, + + { name: 'Diamond Rod', cost: 100000, rarityBoost: 15, specialEffect: 'LUCKY_STREAK' }, + + { name: 'Legendary Rod', cost: 500000, rarityBoost: 25, specialEffect: 'DOUBLE_CATCH' } + +]; + +``` + + + +**Integration:** + +- Extend existing item system + +- Add rod selection/equipping + +- Track equipped rod per user + + + +**Virtual Fisher Inspiration:** "Fishing rods can get you better and more valuable fish" + + + +--- + + + +#### 2.2 Bait System ⭐⭐⭐⭐ + +**Priority: HIGH** | **Complexity: Medium** | **Impact: High** + + + +**Description:** + +Consumable baits that attract specific fish types or rarities. + + + +**Benefits:** + +- Strategic resource management + +- Targeted fishing for specific species + +- Additional shop items + + + +**Bait Types:** + +```typescript + +interface Bait extends Item { + + effect: BaitEffect; + + targetRarity?: Rarity; + + targetSpecies?: string[]; // Specific fish IDs + + targetLocation?: string; // Works best in certain locations + +} + + + +enum BaitEffect { + + RARITY_TARGET = 'RARITY_TARGET', // Increases chance of specific rarity + + SPECIES_ATTRACT = 'SPECIES_ATTRACT', // Only catches certain species + + XP_BOOST = 'XP_BOOST', // Bonus XP (if levels exist) + + TREASURE_CHANCE = 'TREASURE_CHANCE' // Increases treasure find rate + +} + +``` + + + +**Examples:** + +- Bread Crumbs: Attracts common fish + +- Shiny Lure: +30% legendary chance + +- Worms: Best for freshwater locations + +- Magical Bait: Attracts mythical fish + + + +**Virtual Fisher Inspiration:** "Bait allows you to catch better and more fish, unless the user uses special bait" + + + +--- + + + +#### 2.3 Boats/Fishing Vessels ⭐⭐⭐ + +**Priority: MEDIUM** | **Complexity: Medium** | **Impact: Medium** + + + +**Description:** + +Purchasable boats that reduce cooldowns or unlock deep-sea fishing. + + + +**Benefits:** + +- Cooldown reduction mechanic + +- Unlock gated content (deep-sea locations) + +- Prestige item + + + +**Implementation:** + +```typescript + +interface Boat extends Item { + + cooldownReduction: number; // Percentage reduction + + unlocksLocations?: string[]; // Enables access to specific locations + + capacity?: number; // Catch multiple fish per cast + +} + + + +const boats = [ + + { name: 'Rowboat', cost: 10000, cooldownReduction: 10 }, + + { name: 'Sailboat', cost: 50000, cooldownReduction: 20, unlocksLocations: ['DEEP_SEA'] }, + + { name: 'Yacht', cost: 250000, cooldownReduction: 30, capacity: 2 } + +]; + +``` + + + +**Virtual Fisher Inspiration:** "Boats decrease your wait time in between each cast" + + + +--- + + + +### 🏆 Category 3: Goals & Achievements + + + +#### 3.1 Daily/Weekly Quests ⭐⭐⭐⭐⭐ + +**Priority: HIGH** | **Complexity: Medium** | **Impact: Very High** + + + +**Description:** + +Rotating quests with rewards that reset daily/weekly. + + + +**Benefits:** + +- Daily engagement driver + +- Clear short-term goals + +- Reward structure beyond money + +- Adds variety to fishing + + + +**Quest Types:** + +```typescript + +interface Quest { + + id: string; + + title: string; + + description: string; + + type: QuestType; + + goal: number; + + progress: number; + + reward: QuestReward; + + resetInterval: 'DAILY' | 'WEEKLY'; + + expiresAt: Date; + +} + + + +enum QuestType { + + CATCH_COUNT = 'CATCH_COUNT', // Catch X fish + + CATCH_RARITY = 'CATCH_RARITY', // Catch X rare/legendary fish + + CATCH_SPECIES = 'CATCH_SPECIES', // Catch specific species + + CATCH_LOCATION = 'CATCH_LOCATION', // Fish in specific location X times + + EARN_MONEY = 'EARN_MONEY', // Earn X money from fishing + + CATCH_SIZE = 'CATCH_SIZE', // Catch fish over X size (if size system exists) + + FIRST_CATCH = 'FIRST_CATCH' // Be first to catch a new species + +} + + + +interface QuestReward { + + money?: number; + + items?: { itemId: string; quantity: number }[]; + + xp?: number; + + title?: string; // Cosmetic title + +} + +``` + + + +**Example Quests:** + +- "Catch 10 fish today" → Reward: 1,000 coins + +- "Catch 3 Legendary fish this week" → Reward: Golden Lure (bait item) + +- "Fish in 5 different locations" → Reward: 5,000 coins + Explorer Rod + +- "Be the first to catch a Midnight Bass" → Reward: Exclusive title + + + +**Commands:** + +- `/fishing quests` - View active quests and progress + +- `/fishing quest claim [id]` - Claim completed quest rewards + + + +**Virtual Fisher Inspiration:** "Quests can be completed each day for rewards" + + + +--- + + + +#### 3.2 Achievement System ⭐⭐⭐⭐ + +**Priority: HIGH** | **Complexity: Medium** | **Impact: High** + + + +**Description:** + +Permanent achievements for long-term goals with rewards and bragging rights. + + + +**Benefits:** + +- Long-term engagement + +- Collection/completion goals + +- Profile showcasing + +- Reward structure + + + +**Achievement Categories:** + +```typescript + +interface Achievement { + + id: string; + + category: AchievementCategory; + + name: string; + + description: string; + + icon: string; + + tiers?: AchievementTier[]; // Multiple tiers (Bronze/Silver/Gold) + + reward?: AchievementReward; + + hidden?: boolean; // Secret achievements + +} + + + +enum AchievementCategory { + + CATCHING = 'CATCHING', // Catch-related achievements + + COLLECTION = 'COLLECTION', // Collection completion + + WEALTH = 'WEALTH', // Money milestones + + EXPLORATION = 'EXPLORATION', // Location discoveries + + MASTERY = 'MASTERY' // Expert-level achievements + +} + + + +interface AchievementTier { + + tier: 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINUM'; + + requirement: number; + + reward: AchievementReward; + +} + +``` + + + +**Example Achievements:** + + + +**Catching Category:** + +- "First Cast" - Catch your first fish + +- "Lucky Streak" - Catch 3 legendary fish in a row + +- "Century Club" - Catch 100 fish (Bronze: 100, Silver: 500, Gold: 1000) + +- "Rarity Hunter" - Catch at least one of every rarity + +- "Speed Demon" - Catch 50 fish in one day + + + +**Collection Category:** + +- "Ocean Master" - Catch all ocean fish + +- "Complete Collection" - Catch all catchables + +- "Legendary Collector" - Catch all legendary fish + +- "Rainbow Catcher" - Catch one fish of each rarity in one day + + + +**Wealth Category:** + +- "First Fortune" - Earn 10,000 coins total + +- "Millionaire" - Earn 1,000,000 coins total + +- "Bank Breaker" - Have 100,000 coins at once + +- "Big Catch" - Catch a fish worth over 5,000 coins + + + +**Exploration Category:** + +- "World Traveler" - Fish in all locations + +- "Deep Sea Diver" - Unlock deep-sea fishing + +- "Pioneer" - Be first to catch 10 different species + + + +**Mastery Category:** + +- "Fishing Legend" - Reach max level/prestige + +- "Perfect Day" - Complete all daily quests in one day + +- "Dedicated Angler" - Fish 30 days in a row + + + +**Commands:** + +- `/fishing achievements` - View achievement progress + +- `/fishing showcase [achievement]` - Show off an achievement + + + +--- + + + +#### 3.3 Fishing Journal/Pokedex ⭐⭐⭐⭐ + +**Priority: HIGH** | **Complexity: Low-Medium** | **Impact: High** + + + +**Description:** + +A comprehensive catalog tracking all discovered fish with detailed stats. + + + +**Benefits:** + +- Collection completionist goal + +- Information reference + +- Progress visualization + +- Encourages catching all species + + + +**Journal Entry:** + +```typescript + +interface JournalEntry { + + userId: string; + + catchableId: string; + + discovered: boolean; + + firstCaughtAt?: Date; + + timesCaught: number; + + largestSize?: number; // If size system exists + + smallestSize?: number; + + totalValue: number; + + lastCaughtAt?: Date; + +} + +``` + + + +**Display Information:** + +```typescript + +// Journal shows per species: + +- Species name and image + +- Rarity and location + +- Discovery status (Caught / Not Caught / Silhouette) + +- Personal stats: Times caught, first caught date + +- Size records (if size system) + +- Total value earned from species + +- Flavor text/description + +- "First Catch" badge if user was first globally + +``` + + + +**Commands:** + +- `/fishing journal [species]` - View detailed journal entry + +- `/fishing journal list [location/rarity]` - Browse journal with filters + +- `/fishing completion` - View collection completion percentage + + + +**Webfishing Inspiration:** Uses journal collection systems for different fish types + + + +--- + + + +### 🎲 Category 4: Fishing Mechanics & Variety + + + +#### 4.1 Fish Size Variation ⭐⭐⭐⭐⭐ + +**Priority: HIGH** | **Complexity: Low** | **Impact: High** + + + +**Description:** + +Each caught fish has randomized size/weight affecting its value. + + + +**Benefits:** + +- Easy to implement + +- Adds excitement and variety + +- Creates "trophy catch" moments + +- Natural leaderboard category + +- Realistic + + + +**Implementation:** + +```typescript + +interface Catch { + + // ... existing fields + + size: number; // In centimeters or pounds + + isRecordSize?: boolean; // Personal or global record + +} + + + +// Generate size on catch + +function generateFishSize(catchable: Catchable): number { + + const { minSize, maxSize, avgSize } = catchable; + + + + // Use normal distribution centered on avgSize + + // Small chance of very large or very small + + const size = normalDistribution(avgSize, (maxSize - minSize) / 4); + + return Math.max(minSize, Math.min(maxSize, size)); + +} + + + +// Modify worth based on size + +function calculateWorthBySize(baseWorth: number, size: number, avgSize: number): number { + + const sizeRatio = size / avgSize; + + return Math.floor(baseWorth * sizeRatio); + +} + +``` + + + +**Features:** + +- Size ranges per species (min/avg/max) + +- Worth scales with size + +- Size records tracking (personal/global) + +- "Trophy fish" threshold (95th percentile) + +- Size leaderboards per species + + + +**Display Example:** + +``` + +🎣 You caught a **Rare** Golden Trout! + +📏 Size: 47.3 cm (Trophy Size! 🏆) + +💰 Value: 2,847 coins (+15% size bonus) + +⭐ New Personal Record! + +``` + + + +**Leaderboards:** + +- `/fishing leaderboard biggest [species]` - Largest catches per species + +- `/fishing records` - Your personal size records + + + +--- + + + +#### 4.2 Treasure & Junk System ⭐⭐⭐⭐ + +**Priority: MEDIUM-HIGH** | **Complexity: Low-Medium** | **Impact: High** + + + +**Description:** + +Occasionally catch treasure chests or junk items instead of fish. + + + +**Benefits:** + +- Surprise mechanics + +- Additional rewards + +- Humor and variety + +- Risk/reward balance + + + +**Implementation:** + +```typescript + +enum CatchType { + + FISH = 'FISH', + + TREASURE = 'TREASURE', + + JUNK = 'JUNK' + +} + + + +interface TreasureItem { + + name: string; + + rarity: Rarity; + + contents: TreasureReward; + +} + + + +interface TreasureReward { + + money?: number; + + items?: ItemDrop[]; + + guaranteedItem?: string; // Specific item ID + +} + + + +// Catch determination + +function determineCatchType(): CatchType { + + const roll = Math.random() * 100; + + if (roll < 2) return CatchType.TREASURE; // 2% chance + + if (roll < 7) return CatchType.JUNK; // 5% chance + + return CatchType.FISH; // 93% chance + +} + +``` + + + +**Treasure Types:** + +- Wooden Chest: 500-2000 coins + +- Silver Chest: 2000-5000 coins + random item + +- Golden Chest: 5000-15000 coins + rare item + +- Legendary Chest: 20000-50000 coins + legendary rod/bait + +- Sunken Treasure: Rare spawn, massive rewards + + + +**Junk Types:** + +- Old Boot: Sells for 1 coin + +- Seaweed: Sells for 5 coins (or crafting material) + +- Rusty Can: Sells for 3 coins + +- Broken Bottle: Sells for 2 coins + +- Driftwood: Sells for 10 coins + + + +**Special Mechanic:** + +```typescript + +// Junk can be converted/recycled + +- Collect 100 junk → Recycle for random item + +- Certain junk combinations → Craft items + +- "Junk Collector" achievement + +``` + + + +--- + + + +#### 4.3 Fishing Minigame (Optional) ⭐⭐ + +**Priority: LOW** | **Complexity: High** | **Impact: Medium** + + + +**Description:** + +Optional skill-based button-timing minigame for better catches. + + + +**Benefits:** + +- Skill expression + +- Active engagement + +- Better rewards for participation + + + +**Implementation Approach:** + +```typescript + +// Discord button-based minigame + +// User has 10 seconds to click buttons in sequence + +// Success rate affects catch outcome + + + +interface MiniGameResult { + + success: boolean; + + perfectCatch: boolean; // All buttons hit perfectly + + bonusMultiplier: number; // 1.0 - 2.0 based on performance + +} + + + +// Apply bonus to catch + +if (miniGameResult.perfectCatch) { + + // Guarantee next rarity tier up + + // 2x value multiplier + +} + +``` + + + +**Opt-in/Out:** + +- Users can toggle minigame on/off + +- Minigame provides bonuses but isn't required + +- Auto-fish mode bypasses minigame + + + +**Note:** This may be complex for Discord's interaction model and cooldowns + + + +--- + + + +#### 4.4 Seasonal & Event Fish ⭐⭐⭐⭐ + +**Priority: MEDIUM-HIGH** | **Complexity: Medium** | **Impact: High** + + + +**Description:** + +Limited-time fish that appear during specific seasons or events. + + + +**Benefits:** + +- Creates urgency and FOMO + +- Seasonal engagement spikes + +- Special event tie-ins + +- Collectible exclusivity + + + +**Implementation:** + +```typescript + +interface Catchable { + + // ... existing fields + + seasonal?: SeasonalAvailability; + + eventId?: string; // Links to specific event + +} + + + +interface SeasonalAvailability { + + season?: 'SPRING' | 'SUMMER' | 'FALL' | 'WINTER'; + + months?: number[]; // 1-12 + + startDate?: Date; + + endDate?: Date; + +} + + + +// Examples + +const seasonalFish = [ + + { + + name: 'Pumpkin Bass', + + seasonal: { months: [10] }, // October only + + rarity: 'RARE' + + }, + + { + + name: 'Candy Cane Shark', + + seasonal: { months: [12] }, // December only + + rarity: 'LEGENDARY' + + }, + + { + + name: 'Spring Salmon', + + seasonal: { season: 'SPRING' }, + + rarity: 'UNCOMMON' + + } + +]; + +``` + + + +**Event Examples:** + +- Halloween: Spooky fish (Ghost Carp, Vampire Squid) + +- Christmas: Festive fish (Candy Cane Fish, Snowflake Eel) + +- Summer: Tropical event fish + +- Anniversary: Exclusive anniversary fish + + + +**Commands:** + +- `/fishing events` - View active/upcoming events + +- `/fishing seasonal` - See what's currently available + + + +--- + + + +### 📈 Category 5: Progression & Economy + + + +#### 5.1 Leveling System ⭐⭐⭐⭐ + +**Priority: MEDIUM-HIGH** | **Complexity: Medium** | **Impact: High** + + + +**Description:** + +Fishing-specific leveling system with unlocks and bonuses. + + + +**Benefits:** + +- Clear progression metric + +- Unlocks gated content + +- Sense of advancement + +- Prestige building + + + +**Implementation:** + +```typescript + +interface FishingProgress { + + userId: string; + + level: number; + + xp: number; + + xpToNextLevel: number; + + totalXP: number; + +} + + + +// XP sources + +function calculateXP(catch: Catch): number { + + const baseXP = { + + COMMON: 10, + + UNCOMMON: 25, + + RARE: 50, + + LEGENDARY: 100 + + }[catch.rarity]; + + + + const sizeBonus = catch.size > catch.catchable.avgSize ? 1.2 : 1.0; + + const firstCatchBonus = catch.isFirstCatch ? 2.0 : 1.0; + + + + return Math.floor(baseXP * sizeBonus * firstCatchBonus); + +} + + + +// Level unlocks + +const levelUnlocks = { + + 5: { unlock: 'LOCATION', locationId: 'LAKE' }, + + 10: { unlock: 'ROD', rodId: 'IRON_ROD' }, + + 15: { unlock: 'LOCATION', locationId: 'OCEAN' }, + + 20: { unlock: 'ABILITY', ability: 'DOUBLE_CAST_CHANCE' }, + + 25: { unlock: 'LOCATION', locationId: 'DEEP_SEA' }, + + 30: { unlock: 'ROD', rodId: 'GOLD_ROD' }, + + // ... + + 100: { unlock: 'PRESTIGE_UNLOCK' } + +}; + +``` + + + +**Level Benefits:** + +- Unlock new locations + +- Unlock better equipment in shop + +- Passive bonuses (+1% rarity boost per 10 levels) + +- Cooldown reductions + +- Increased catch limits + + + +**Virtual Fisher Inspiration:** "proper progression based on fishing, increasing buffs and upgrading fishing gear" + + + +--- + + + +#### 5.2 Prestige System ⭐⭐⭐ + +**Priority: MEDIUM** | **Complexity: Medium** | **Impact: Medium-High** + + + +**Description:** + +Reset progress at max level for permanent bonuses and prestige status. + + + +**Benefits:** + +- Endgame content + +- Repeatable progression + +- Elite status display + +- Permanent advantage accumulation + + + +**Implementation:** + +```typescript + +interface PrestigeData { + + userId: string; + + prestigeLevel: number; + + totalPrestiges: number; + + prestigedAt: Date[]; + +} + + + +// Requirements + +const PRESTIGE_REQUIREMENTS = { + + minLevel: 100, + + cost: 1000000, // 1 million coins + + mustHave: 'LEGENDARY_ROD' + +}; + + + +// Prestige bonuses (permanent) + +function getPrestigeBonuses(prestigeLevel: number) { + + return { + + rarityBoost: prestigeLevel * 2, // +2% per prestige + + worthMultiplier: 1 + (prestigeLevel * 0.05), // +5% per prestige + + xpMultiplier: 1 + (prestigeLevel * 0.1), // +10% faster leveling + + cooldownReduction: prestigeLevel * 2, // -2% cooldown per prestige + + startingRod: prestigeLevel > 3 ? 'GOLD_ROD' : 'IRON_ROD' + + }; + +} + + + +// What resets + +- Level → 1 + +- XP → 0 + +- Most items (keeps prestige-specific items) + +- Unlocked locations (must unlock again) + + + +// What persists + +- Money + +- Achievements + +- Journal/collection + +- Prestige bonuses + +- Prestige-exclusive items + +- First catch records + +``` + + + +**Prestige Rewards:** + +- Prestige badge/title + +- Exclusive prestige-only fish + +- Permanent stat boosts + +- Cosmetic rewards (fish colors, profile borders) + +- Faster progression on subsequent runs + + + +**Virtual Fisher Inspiration:** "When you reach level 250, you have the option to prestige, which resets your progress but adds huge helpful bonuses" + + + +--- + + + +#### 5.3 Fishing Crews/Guilds ⭐⭐⭐ + +**Priority: LOW-MEDIUM** | **Complexity: High** | **Impact: Medium** + + + +**Description:** + +Form fishing crews with shared goals and benefits. + + + +**Benefits:** + +- Social engagement + +- Team competition + +- Collaborative goals + +- Retention through social bonds + + + +**Implementation:** + +```typescript + +interface FishingCrew { + + id: string; + + name: string; + + description: string; + + leaderId: string; + + members: string[]; // User IDs + + maxMembers: number; + + level: number; + + xp: number; + + createdAt: Date; + + perks: CrewPerk[]; + +} + + + +interface CrewPerk { + + type: 'RARITY_BOOST' | 'WORTH_MULTIPLIER' | 'XP_BOOST' | 'COOLDOWN_REDUCTION'; + + value: number; + +} + + + +// Crew activities + +- Crew quests (collective goals) + +- Crew competitions vs other crews + +- Shared crew bank for equipment + +- Crew-only fish + +- Crew leaderboards + +``` + + + +**Crew Benefits:** + +- Small stat bonuses when crew levels up + +- Crew-exclusive quests + +- Crew fishing tournaments + +- Shared knowledge (crew members can see each other's journals) + + + +**Virtual Fisher Inspiration:** "Creating your own clans" + + + +--- + + + +### 🎨 Category 6: Quality of Life & Polish + + + +#### 6.1 Auto-Fishing Enhancements ⭐⭐⭐⭐ + +**Priority: MEDIUM-HIGH** | **Complexity: Low-Medium** | **Impact: Medium** + + + +**Description:** + +Improve existing auto-fishing with better controls and rewards. + + + +**Current State:** + +- Auto-fishing status shown in `/fishing stats` + + + +**Enhancements:** + +```typescript + +interface AutoFishingSettings { + + userId: string; + + enabled: boolean; + + location?: string; // Auto-fish at specific location + + preferredBait?: string; // Auto-use specific bait + + sellAutomatically?: boolean; // Auto-sell catches + + minRarityToKeep?: Rarity; // Keep certain rarities + + notifyOnLegendary?: boolean; // Alert on legendary catch + + maxDailyAutoFish?: number; // Cap auto-fishing + +} + + + +// Commands + +- `/fishing auto enable [location]` - Enable auto-fishing + +- `/fishing auto settings` - Configure auto-fish behavior + +- `/fishing auto disable` - Disable auto-fishing + +``` + + + +**Auto-Fishing Benefits:** + +- Passive progression + +- Away-from-keyboard fishing + +- Configurable automation + + + +**Balancing:** + +- Lower rates than manual fishing + +- No minigame bonuses + +- Reduced XP (if XP system) + +- Doesn't complete quests + +- Limited daily auto-catches + + + +--- + + + +#### 6.2 Fishing Tournaments ⭐⭐⭐⭐ + +**Priority: MEDIUM** | **Complexity: High** | **Impact: High** + + + +**Description:** + +Time-limited competitive events with leaderboards and prizes. + + + +**Benefits:** + +- Competitive engagement + +- Community events + +- Excitement and urgency + +- Reward distribution + + + +**Tournament Types:** + +```typescript + +interface Tournament { + + id: string; + + name: string; + + type: TournamentType; + + startTime: Date; + + endTime: Date; + + prizes: TournamentPrize[]; + + participants: TournamentEntry[]; + + rules: TournamentRules; + +} + + + +enum TournamentType { + + MOST_CATCHES = 'MOST_CATCHES', // Who catches the most + + HIGHEST_VALUE = 'HIGHEST_VALUE', // Total value caught + + BIGGEST_FISH = 'BIGGEST_FISH', // Largest single fish + + RARITY_HUNT = 'RARITY_HUNT', // Most legendary catches + + SPECIFIC_SPECIES = 'SPECIFIC_SPECIES', // Most of one species + + FASTEST = 'FASTEST' // First to catch X fish + +} + + + +interface TournamentRules { + + location?: string; // Must fish at specific location + + allowedRarities?: Rarity[]; // Only certain rarities count + + targetSpecies?: string[]; // Only certain fish count + + maxEntries?: number; // Limited participation + +} + +``` + + + +**Tournament Schedule:** + +- Weekend tournaments (Friday-Sunday) + +- Monthly mega-tournaments + +- Surprise flash tournaments (2-hour events) + +- Seasonal championships + + + +**Prizes:** + +- Top 3: Exclusive items/rods/titles + +- Participation rewards for all + +- Tiered prizes (Top 10, Top 25, Top 100) + +- Unique tournament-exclusive fish + + + +**Commands:** + +- `/fishing tournament` - View active tournament + +- `/fishing tournament join` - Enter tournament + +- `/fishing tournament leaderboard` - View standings + +- `/fishing tournament history` - Past tournaments and winners + + + +--- + + + +#### 6.3 Trading System ⭐⭐ + +**Priority: LOW** | **Complexity: High** | **Impact: Low-Medium** + + + +**Description:** + +Trade fish and items with other users. + + + +**Benefits:** + +- Social interaction + +- Market dynamics + +- Helps complete collections + +- Economy depth + + + +**Implementation:** + +```typescript + +interface Trade { + + id: string; + + initiatorId: string; + + recipientId: string; + + initiatorOffer: TradeOffer; + + recipientOffer: TradeOffer; + + status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'CANCELLED'; + + createdAt: Date; + +} + + + +interface TradeOffer { + + money?: number; + + items?: { itemId: string; quantity: number }[]; + + fish?: string[]; // Specific catch IDs + +} + +``` + + + +**Commands:** + +- `/fishing trade @user` - Initiate trade + +- `/fishing trade offer [items]` - Add to trade + +- `/fishing trade accept` - Accept trade + +- `/fishing marketplace` - Public marketplace for listings + + + +**Considerations:** + +- Prevent scamming (confirmation screens) + +- Trade history logging + +- Cooldowns on trades + +- Possible tax on trades + + + +--- + + + +#### 6.4 Cosmetic Customization ⭐⭐ + +**Priority: LOW** | **Complexity: Medium** | **Impact: Low** + + + +**Description:** + +Customize fishing profile with titles, badges, colors. + + + +**Examples:** + +- Titles: "Master Angler", "Deep Sea Legend", "First Fisher" + +- Profile backgrounds for `/fishing stats` + +- Custom fish emoji/colors in catches + +- Fishing card customization + + + +--- + + + +### 🔬 Category 7: Advanced/Experimental Features + + + +#### 7.1 Fish Breeding/Aquarium ⭐⭐ + +**Priority: LOW** | **Complexity: Very High** | **Impact: Medium** + + + +**Description:** + +Keep caught fish in an aquarium and breed them. + + + +**Note:** This is complex and may be outside scope, but interesting for long-term. + + + +--- + + + +#### 7.2 Dynamic Fish Market ⭐⭐ + +**Priority: LOW** | **Complexity: High** | **Impact: Medium** + + + +**Description:** + +Fish values fluctuate based on supply and demand. + + + +**Implementation:** + +- Track global catches per species + +- High supply = lower price + +- Low supply = higher price + +- Market trends and predictions + + + +--- + + + +## Priority Matrix + + + +### Implementation Priority (Recommended Order) + + + +#### **Phase 1: Core Expansions** (High Impact, Med-Low Complexity) + +1. ✅ **Fish Size Variation** - Quick win, high impact + +2. ✅ **Fishing Locations/Biomes** - Major content expansion + +3. ✅ **Fishing Rods** - Clear progression path + +4. ✅ **Fishing Journal/Pokedex** - Collection tracking + + + +#### **Phase 2: Engagement Systems** (High Impact, Medium Complexity) + +5. ✅ **Daily/Weekly Quests** - Daily engagement driver + +6. ✅ **Achievement System** - Long-term goals + +7. ✅ **Bait System** - Strategic depth + +8. ✅ **Seasonal & Event Fish** - Timed content + + + +#### **Phase 3: Competition & Social** (Medium-High Impact, High Complexity) + +9. ✅ **Leveling System** - Progression framework + +10. ✅ **Fishing Tournaments** - Competitive events + +11. ✅ **Treasure & Junk** - Variety and surprise + +12. ✅ **Auto-Fishing Enhancements** - QoL improvement + + + +#### **Phase 4: Advanced Features** (Medium Impact, Medium-High Complexity) + +13. ⚠️ **Weather System** - Environmental variety + +14. ⚠️ **Prestige System** - Endgame content + +15. ⚠️ **Time-Based Fishing** - Day/night cycle + +16. ⚠️ **Boats/Vessels** - Cooldown/location unlocks + + + +#### **Phase 5: Optional/Future** (Lower Priority) + +17. 🔮 **Fishing Crews/Guilds** - Social systems + +18. 🔮 **Trading System** - Player economy + +19. 🔮 **Cosmetic Customization** - Personalization + +20. 🔮 **Fishing Minigame** - Active engagement + +21. 🔮 **Dynamic Market** - Economic simulation + +22. 🔮 **Fish Breeding** - Complex endgame + + + +--- + + + +## Technical Considerations + + + +### Database Schema Changes Required + + + +**New Tables:** + +- `locations` - Fishing location definitions + +- `quests` - Quest templates and active quests + +- `user_quests` - User quest progress + +- `achievements` - Achievement definitions + +- `user_achievements` - Unlocked achievements + +- `journal_entries` - User's discovered fish catalog + +- `fishing_progress` - User level/XP data + +- `prestige_data` - User prestige information + +- `tournaments` - Tournament definitions + +- `tournament_entries` - User tournament participation + +- `size_records` - Personal and global size records + +- `crews` - Fishing crew/guild data + +- `crew_members` - Crew membership + + + +**Modified Tables:** + +- `catchables` - Add: locationId, minSize, maxSize, avgSize, seasonal, eventId + +- `catches` - Add: size, locationId, tournamentId, xpEarned + +- `users` - Add: equippedRod, equippedBoat, selectedLocation, autoFishingEnabled + +- `items` - Expand effect system for rods, bait, boats + + + +### Service Layer Additions + + + +**New Services:** + +- `LocationService` - Location management and unlocking + +- `QuestService` - Quest generation, progress tracking, rewards + +- `AchievementService` - Achievement checking and awarding + +- `JournalService` - Fish discovery tracking + +- `ProgressionService` - Leveling and XP management + +- `PrestigeService` - Prestige system handling + +- `TournamentService` - Tournament lifecycle management + +- `SizeService` - Size generation and record tracking + +- `SeasonalService` - Seasonal/event fish availability + + + +**Enhanced Services:** + +- `FishingService` - Integrate all new systems + +- `ItemEffectsService` - Expand for rods, bait, boats + + + +### Performance Considerations + + + +1. **Caching Strategy:** + + - Cache location definitions + + - Cache catchable pools per location + + - Cache active quests per user + + - Cache leaderboards (refresh every 5 min) + + + +2. **Database Optimization:** + + - Index on locationId, rarity, seasonal fields + + - Compound indexes for common queries + + - Archive old tournament data + + + +3. **Rate Limiting:** + + - Existing cooldown system handles this well + + - May need quest claim rate limiting + + + +--- + + + +## Integration with Existing Systems + + + +### ✅ Strengths to Leverage + + + +1. **Item Effects System** - Already supports rarity boost and worth multipliers + + - Easily extend for rods and bait + + - Service architecture is clean + + + +2. **Cooldown System** - Well-implemented rolling window + + - Can be modified per location or by boat bonuses + + - Guild context already handled + + + +3. **First Catch Tracking** - Great foundation + + - Expands naturally to journal system + + - Achievement integration ready + + + +4. **Statistics System** - Already tracking key metrics + + - Easy to add size records + + - Quest progress tracking similar + + + +### ⚠️ Potential Challenges + + + +1. **Database Growth** - Many new tables and relationships + + - Migration strategy needed + + - Consider archival for old tournament/catch data + + + +2. **Command Complexity** - Many new subcommands + + - May need command grouping/organization + + - Help documentation expansion + + + +3. **Balance Tuning** - Many interconnected systems + + - Rarity calculations become more complex + + - Economy balance with new money sources + + + +--- + + + +## Success Metrics + + + +### Engagement Metrics + +- Daily active fishers (DAF) + +- Average catches per user per day + +- Quest completion rate + +- Achievement unlock rate + +- Collection completion rate + +- Tournament participation rate + + + +### Economy Metrics + +- Average user balance + +- Item purchase rates + +- Trade volume (if trading implemented) + +- Prestige rate + + + +### Content Metrics + +- Fish diversity in catches (are all rarities caught?) + +- Location usage distribution + +- Bait/rod usage rates + +- Seasonal fish catch rates + + + +--- + + + +## Conclusion + + + +The fishing system has a solid foundation. The highest-impact additions would be: + + + +🥇 **Top 3 Must-Have Features:** + +1. **Locations/Biomes** - Massively expands content and variety + +2. **Quests & Achievements** - Drives daily engagement and long-term goals + +3. **Size Variation** - Easy to implement, huge excitement factor + + + +🥈 **Next Tier (Great Additions):** + +4. **Fishing Rods** - Clear progression path + +5. **Journal/Pokedex** - Collection completionist appeal + +6. **Bait System** - Strategic depth + +7. **Seasonal Fish** - Timed engagement + + + +🥉 **Nice-to-Have (Later):** + +8. **Leveling System** - Structured progression + +9. **Tournaments** - Competitive events + +10. **Prestige** - Endgame content + + + +These features build on each other and can be implemented incrementally. Start with Phase 1 (Size, Locations, Rods, Journal) for maximum impact with reasonable complexity. + + + +--- + + + +**End of Research Document** + + + +*For questions or implementation discussions, reference specific sections by feature name.* \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9669cb6..6534851 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,12 +44,14 @@ "@types/remove-markdown": "0.3.4", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", + "@vitest/coverage-v8": "^4.0.9", "drizzle-kit": "^0.31.4", "eslint": "^8.57.1", "eslint-plugin-import": "^2.32.0", "eslint-plugin-unicorn": "^48.0.1", "prettier": "^3.6.2", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^4.0.9" }, "engines": { "node": ">=16.11.0" @@ -95,6 +97,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", @@ -105,6 +117,46 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@discordjs/builders": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.0.tgz", @@ -1262,6 +1314,34 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@msgpack/msgpack": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.2.tgz", @@ -1587,6 +1667,314 @@ "debug": "^4.3.1" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", + "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", + "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", + "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", + "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", + "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", + "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", + "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", + "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", + "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", + "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", + "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", + "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", + "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", + "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", + "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", + "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", + "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", + "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", + "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", + "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", + "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", + "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1627,6 +2015,13 @@ "npm": ">=7.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -1644,6 +2039,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1654,6 +2060,20 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -2003,6 +2423,149 @@ "dev": true, "license": "ISC" }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.9.tgz", + "integrity": "sha512-70oyhP+Q0HlWBIeGSP74YBw5KSjYhNgSCQjvmuQFciMqnyF36WL2cIkcT7XD85G4JPmBQitEMUsx+XMFv2AzQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.9", + "ast-v8-to-istanbul": "^0.3.8", + "debug": "^4.4.3", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.9", + "vitest": "4.0.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.9.tgz", + "integrity": "sha512-C2vyXf5/Jfj1vl4DQYxjib3jzyuswMi/KHHVN2z+H4v16hdJ7jMZ0OGe3uOVIt6LyJsAofDdaJNIFEpQcrSTFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.9", + "@vitest/utils": "4.0.9", + "chai": "^6.2.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.9.tgz", + "integrity": "sha512-PUyaowQFHW+9FKb4dsvvBM4o025rWMlEDXdWRxIOilGaHREYTi5Q2Rt9VCgXgPy/hHZu1LeuXtrA/GdzOatP2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.9.tgz", + "integrity": "sha512-Hor0IBTwEi/uZqB7pvGepyElaM8J75pYjrrqbC8ZYMB9/4n5QA63KC15xhT+sqHpdGWfdnPo96E8lQUxs2YzSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.9.tgz", + "integrity": "sha512-aF77tsXdEvIJRkj9uJZnHtovsVIx22Ambft9HudC+XuG/on1NY/bf5dlDti1N35eJT+QZLb4RF/5dTIG18s98w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.9.tgz", + "integrity": "sha512-r1qR4oYstPbnOjg0Vgd3E8ADJbi4ditCzqr+Z9foUrRhIy778BleNyZMeAJ2EjV+r4ASAaDsdciC9ryMy8xMMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.9.tgz", + "integrity": "sha512-J9Ttsq0hDXmxmT8CUOWUr1cqqAj2FJRGTdyEjSR+NjoOGKEqkEWj+09yC0HhI8t1W6t4Ctqawl1onHgipJve1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.9.tgz", + "integrity": "sha512-cEol6ygTzY4rUPvNZM19sDf7zGa35IYTm9wfzkHoT/f5jX10IOY7QleWSOh5T0e3I3WVozwK5Asom79qW8DiuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.9", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vladfrangu/async_event_emitter": { "version": "2.4.7", "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", @@ -2299,6 +2862,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -2311,6 +2884,25 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2532,6 +3124,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3446,6 +4048,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3969,6 +4578,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4002,6 +4621,16 @@ "node": ">=14.18" } }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -4783,6 +5412,13 @@ "dev": true, "license": "ISC" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -5526,7 +6162,61 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "ISC" + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } }, "node_modules/joycon": { "version": "3.1.1", @@ -5756,6 +6446,44 @@ "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5905,6 +6633,25 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "license": "ISC" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6448,6 +7195,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6721,6 +7475,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", @@ -7284,6 +8067,48 @@ "integrity": "sha512-hzjy826lrxzx8eRgv80idkf8ua1JAepRc9Efdtj03N3KNJuznQCPlyCJ7gnUmDFwZCLQjxy567mQVKmdv2BsXQ==", "license": "BSD-2-Clause" }, + "node_modules/rollup": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", + "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.2", + "@rollup/rollup-android-arm64": "4.53.2", + "@rollup/rollup-darwin-arm64": "4.53.2", + "@rollup/rollup-darwin-x64": "4.53.2", + "@rollup/rollup-freebsd-arm64": "4.53.2", + "@rollup/rollup-freebsd-x64": "4.53.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", + "@rollup/rollup-linux-arm-musleabihf": "4.53.2", + "@rollup/rollup-linux-arm64-gnu": "4.53.2", + "@rollup/rollup-linux-arm64-musl": "4.53.2", + "@rollup/rollup-linux-loong64-gnu": "4.53.2", + "@rollup/rollup-linux-ppc64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-musl": "4.53.2", + "@rollup/rollup-linux-s390x-gnu": "4.53.2", + "@rollup/rollup-linux-x64-gnu": "4.53.2", + "@rollup/rollup-linux-x64-musl": "4.53.2", + "@rollup/rollup-openharmony-arm64": "4.53.2", + "@rollup/rollup-win32-arm64-msvc": "4.53.2", + "@rollup/rollup-win32-ia32-msvc": "4.53.2", + "@rollup/rollup-win32-x64-gnu": "4.53.2", + "@rollup/rollup-win32-x64-msvc": "4.53.2", + "fsevents": "~2.3.2" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -7668,6 +8493,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -7774,6 +8606,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -7856,6 +8698,13 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7865,6 +8714,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -8097,6 +8953,78 @@ "real-require": "^0.2.0" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8398,6 +9326,203 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", + "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.9.tgz", + "integrity": "sha512-E0Ja2AX4th+CG33yAFRC+d1wFx2pzU5r6HtG6LiPSE04flaE0qB6YyjSw9ZcpJAtVPfsvZGtJlKWZpuW7EHRxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.9", + "@vitest/mocker": "4.0.9", + "@vitest/pretty-format": "4.0.9", + "@vitest/runner": "4.0.9", + "@vitest/snapshot": "4.0.9", + "@vitest/spy": "4.0.9", + "@vitest/utils": "4.0.9", + "debug": "^4.4.3", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.9", + "@vitest/browser-preview": "4.0.9", + "@vitest/browser-webdriverio": "4.0.9", + "@vitest/ui": "4.0.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vizion": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/vizion/-/vizion-2.2.1.tgz", @@ -8536,6 +9661,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", diff --git a/package.json b/package.json index 46116da..baff703 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,11 @@ "lint:fix": "eslint . --fix --cache --ext .js,.jsx,.ts,.tsx", "format": "prettier --check .", "format:fix": "prettier --write .", + "test": "vitest run", + "test:unit": "vitest run tests/unit", + "test:integration": "vitest run tests/integration", + "test:watch": "vitest watch", + "test:coverage": "vitest run --coverage", "clean": "git clean -xdf --exclude=\"/config/**/*\"", "clean:dry": "git clean -xdf --exclude=\"/config/**/*\" --dry-run", "build": "tsc --project tsconfig.json", @@ -55,11 +60,11 @@ "express": "5.1.0", "filesize": "11.0.2", "form-data": "^4.0.4", + "ink": "^6.5.0", "linguini": "1.3.1", "luxon": "3.7.1", "node-fetch": "3.3.2", "node-schedule": "2.1.1", - "ink": "^6.5.0", "openai": "^5.15.0", "pino": "9.9.0", "pino-pretty": "13.1.1", @@ -78,11 +83,13 @@ "@types/remove-markdown": "0.3.4", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", + "@vitest/coverage-v8": "^4.0.9", "drizzle-kit": "^0.31.4", "eslint": "^8.57.1", "eslint-plugin-import": "^2.32.0", "eslint-plugin-unicorn": "^48.0.1", "prettier": "^3.6.2", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^4.0.9" } } diff --git a/src/utils/interaction-utils.ts b/src/utils/interaction-utils.ts index 6aa75d7..bfe50ee 100644 --- a/src/utils/interaction-utils.ts +++ b/src/utils/interaction-utils.ts @@ -5,6 +5,7 @@ import { DiscordAPIError, RESTJSONErrorCodes as DiscordApiErrors, EmbedBuilder, + InteractionCallbackResponse, InteractionReplyOptions, InteractionResponse, InteractionUpdateOptions, @@ -70,7 +71,7 @@ export class InteractionUtils { intr: CommandInteraction | MessageComponentInteraction | ModalSubmitInteraction, content: string | EmbedBuilder | InteractionReplyOptions, hidden: boolean = false - ): Promise { + ): Promise { try { let options: InteractionReplyOptions = typeof content === 'string' @@ -150,7 +151,7 @@ export class InteractionUtils { public static async update( intr: MessageComponentInteraction, content: string | EmbedBuilder | InteractionUpdateOptions - ): Promise { + ): Promise { try { let options: InteractionUpdateOptions = typeof content === 'string' diff --git a/tests/integration/fishing-service.integration.test.ts b/tests/integration/fishing-service.integration.test.ts new file mode 100644 index 0000000..7fe5ad3 --- /dev/null +++ b/tests/integration/fishing-service.integration.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Catchable } from '../../src/db/schema.js'; +import { Rarity } from '../../src/enums/rarity.js'; +import { FishingService } from '../../src/services/fishing.service.js'; + +// Mock the database service +vi.mock('../../src/services/database.service.js', () => ({ + getDb: vi.fn(), +})); + +// Mock the logger +vi.mock('../../src/services/logger.js', () => ({ + Logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('FishingService Integration Tests', () => { + let fishingService: FishingService; + let mockDb: any; + + beforeEach(async () => { + // Reset mocks before each test + vi.clearAllMocks(); + + // Create mock database + mockDb = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + }; + + // Import and mock getDb + const { getDb } = await import('../../src/services/database.service.js'); + vi.mocked(getDb).mockReturnValue(mockDb); + + fishingService = new FishingService(); + }); + + describe('pickCatchableByRarity', () => { + it('should pick a random catchable from available options', async () => { + const mockCatchables: Catchable[] = [ + { + id: '1', + name: 'Common Fish 1', + rarity: Rarity.COMMON, + worth: 10, + image: '🐟', + firstCaughtBy: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: '2', + name: 'Common Fish 2', + rarity: Rarity.COMMON, + worth: 15, + image: '🐠', + firstCaughtBy: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + // Mock the database query to return our test catchables + mockDb.where.mockResolvedValue(mockCatchables); + + const result = await fishingService.pickCatchableByRarity(Rarity.COMMON); + + expect(result).toBeDefined(); + expect(mockCatchables).toContainEqual(result); + expect(result?.rarity).toBe(Rarity.COMMON); + }); + + it('should return null when no catchables are found', async () => { + // Mock the database query to return empty array + mockDb.where.mockResolvedValue([]); + + const result = await fishingService.pickCatchableByRarity(Rarity.LEGENDARY); + + expect(result).toBeNull(); + }); + + it('should pick different catchables from a pool', async () => { + const mockCatchables: Catchable[] = Array.from({ length: 10 }, (_, i) => ({ + id: `${i + 1}`, + name: `Fish ${i + 1}`, + rarity: Rarity.UNCOMMON, + worth: (i + 1) * 10, + image: '🐟', + firstCaughtBy: null, + createdAt: new Date(), + updatedAt: new Date(), + })); + + mockDb.where.mockResolvedValue(mockCatchables); + + // Pick multiple catchables + const picks = await Promise.all( + Array.from({ length: 20 }, () => + fishingService.pickCatchableByRarity(Rarity.UNCOMMON) + ) + ); + + // All picks should be valid catchables + picks.forEach(pick => { + expect(pick).toBeDefined(); + expect(mockCatchables).toContainEqual(pick); + }); + + // With 20 picks from 10 options, we should get some variety + const uniqueIds = new Set(picks.map(p => p?.id)); + expect(uniqueIds.size).toBeGreaterThan(1); + }); + + it('should handle database errors gracefully', async () => { + // Mock a database error + mockDb.where.mockRejectedValue(new Error('Database connection failed')); + + await expect( + fishingService.pickCatchableByRarity(Rarity.RARE) + ).rejects.toThrow('Failed to pick catchable'); + }); + }); + + describe('isFirstCatch', () => { + it('should return true when catchable has never been caught', async () => { + const mockCatchables = [ + { + id: '1', + firstCaughtBy: null, + }, + ]; + + mockDb.limit.mockResolvedValue(mockCatchables); + + const result = await fishingService.isFirstCatch('1'); + + expect(result).toBe(true); + }); + + it('should return false when catchable has already been caught', async () => { + // Empty array means the catchable either doesn't exist or has already been caught + mockDb.limit.mockResolvedValue([]); + + const result = await fishingService.isFirstCatch('1'); + + expect(result).toBe(false); + }); + + it('should handle database errors', async () => { + mockDb.limit.mockRejectedValue(new Error('Database error')); + + await expect(fishingService.isFirstCatch('1')).rejects.toThrow( + 'Failed to check first catch' + ); + }); + }); + + describe('determineRarity (without user effects)', () => { + it('should return a valid rarity', async () => { + // Test multiple times to account for randomness + const results = await Promise.all( + Array.from({ length: 100 }, () => fishingService.determineRarity()) + ); + + results.forEach(rarity => { + expect([ + Rarity.COMMON, + Rarity.UNCOMMON, + Rarity.RARE, + Rarity.LEGENDARY, + ]).toContain(rarity); + }); + }); + + it('should produce common rarity most frequently', async () => { + const results = await Promise.all( + Array.from({ length: 1000 }, () => fishingService.determineRarity()) + ); + + const commonCount = results.filter(r => r === Rarity.COMMON).length; + const uncommonCount = results.filter(r => r === Rarity.UNCOMMON).length; + const rareCount = results.filter(r => r === Rarity.RARE).length; + const legendaryCount = results.filter(r => r === Rarity.LEGENDARY).length; + + // With 1000 rolls, we expect roughly 60% common, 30% uncommon, 8% rare, 2% legendary + // Use loose bounds to account for randomness + expect(commonCount).toBeGreaterThan(500); // Should be around 600 + expect(uncommonCount).toBeGreaterThan(200); // Should be around 300 + expect(rareCount).toBeGreaterThan(30); // Should be around 80 + expect(legendaryCount).toBeGreaterThan(0); // Should be around 20 + + // Common should be more frequent than uncommon + expect(commonCount).toBeGreaterThan(uncommonCount); + expect(uncommonCount).toBeGreaterThan(rareCount); + expect(rareCount).toBeGreaterThan(legendaryCount); + }); + }); + + describe('calculateFinalWorth (without user effects)', () => { + it('should return base worth when no user ID is provided', async () => { + const baseWorth = 100; + const result = await fishingService.calculateFinalWorth(baseWorth); + + expect(result).toBe(baseWorth); + }); + + it('should handle zero base worth', async () => { + const result = await fishingService.calculateFinalWorth(0); + + expect(result).toBe(0); + }); + + it('should handle large base worth values', async () => { + const baseWorth = 1000000; + const result = await fishingService.calculateFinalWorth(baseWorth); + + expect(result).toBe(baseWorth); + }); + }); +}); diff --git a/tests/unit/services/fishing.service.test.ts b/tests/unit/services/fishing.service.test.ts new file mode 100644 index 0000000..1d2c70d --- /dev/null +++ b/tests/unit/services/fishing.service.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Mock the logger to prevent config.json loading +vi.mock('../../../src/services/logger.js', () => ({ + Logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { Rarity } from '../../../src/enums/rarity.js'; +import { FishingService } from '../../../src/services/fishing.service.js'; + +describe('FishingService', () => { + const fishingService = new FishingService(); + + describe('getRarityName', () => { + it('should return "Common" for COMMON rarity', () => { + const result = fishingService.getRarityName(Rarity.COMMON); + expect(result).toBe('Common'); + }); + + it('should return "Uncommon" for UNCOMMON rarity', () => { + const result = fishingService.getRarityName(Rarity.UNCOMMON); + expect(result).toBe('Uncommon'); + }); + + it('should return "Rare" for RARE rarity', () => { + const result = fishingService.getRarityName(Rarity.RARE); + expect(result).toBe('Rare'); + }); + + it('should return "Legendary" for LEGENDARY rarity', () => { + const result = fishingService.getRarityName(Rarity.LEGENDARY); + expect(result).toBe('Legendary'); + }); + + it('should return "Unknown" for invalid rarity', () => { + const result = fishingService.getRarityName(999 as Rarity); + expect(result).toBe('Unknown'); + }); + + it('should return "Unknown" for negative rarity', () => { + const result = fishingService.getRarityName(-1 as Rarity); + expect(result).toBe('Unknown'); + }); + + it('should handle numeric value 0 (COMMON)', () => { + const result = fishingService.getRarityName(0); + expect(result).toBe('Common'); + }); + + it('should handle numeric value 1 (UNCOMMON)', () => { + const result = fishingService.getRarityName(1); + expect(result).toBe('Uncommon'); + }); + + it('should handle numeric value 2 (RARE)', () => { + const result = fishingService.getRarityName(2); + expect(result).toBe('Rare'); + }); + + it('should handle numeric value 3 (LEGENDARY)', () => { + const result = fishingService.getRarityName(3); + expect(result).toBe('Legendary'); + }); + }); + + describe('getRarityColor', () => { + it('should return gray color for COMMON rarity', () => { + const result = fishingService.getRarityColor(Rarity.COMMON); + expect(result).toBe(0x95a5a6); + }); + + it('should return green color for UNCOMMON rarity', () => { + const result = fishingService.getRarityColor(Rarity.UNCOMMON); + expect(result).toBe(0x2ecc71); + }); + + it('should return blue color for RARE rarity', () => { + const result = fishingService.getRarityColor(Rarity.RARE); + expect(result).toBe(0x3498db); + }); + + it('should return gold color for LEGENDARY rarity', () => { + const result = fishingService.getRarityColor(Rarity.LEGENDARY); + expect(result).toBe(0xf39c12); + }); + + it('should return black color for invalid rarity', () => { + const result = fishingService.getRarityColor(999 as Rarity); + expect(result).toBe(0x000000); + }); + + it('should return black color for negative rarity', () => { + const result = fishingService.getRarityColor(-1 as Rarity); + expect(result).toBe(0x000000); + }); + + it('should handle numeric value 0 (COMMON)', () => { + const result = fishingService.getRarityColor(0); + expect(result).toBe(0x95a5a6); + }); + + it('should handle numeric value 1 (UNCOMMON)', () => { + const result = fishingService.getRarityColor(1); + expect(result).toBe(0x2ecc71); + }); + + it('should handle numeric value 2 (RARE)', () => { + const result = fishingService.getRarityColor(2); + expect(result).toBe(0x3498db); + }); + + it('should handle numeric value 3 (LEGENDARY)', () => { + const result = fishingService.getRarityColor(3); + expect(result).toBe(0xf39c12); + }); + + it('should return valid hex color values', () => { + const colors = [ + fishingService.getRarityColor(Rarity.COMMON), + fishingService.getRarityColor(Rarity.UNCOMMON), + fishingService.getRarityColor(Rarity.RARE), + fishingService.getRarityColor(Rarity.LEGENDARY), + ]; + + colors.forEach(color => { + expect(color).toBeGreaterThanOrEqual(0); + expect(color).toBeLessThanOrEqual(0xffffff); + }); + }); + }); +}); diff --git a/tests/unit/utils/format-utils.test.ts b/tests/unit/utils/format-utils.test.ts new file mode 100644 index 0000000..6d8776c --- /dev/null +++ b/tests/unit/utils/format-utils.test.ts @@ -0,0 +1,149 @@ +import { Locale } from 'discord.js'; +import { describe, expect, it } from 'vitest'; + +import { FormatUtils } from '../../../src/utils/format-utils.js'; + +describe('FormatUtils', () => { + describe('channelMention', () => { + it('should format channel mention correctly', () => { + const result = FormatUtils.channelMention('123456789012345678'); + expect(result).toBe('<#123456789012345678>'); + }); + + it('should handle different channel IDs', () => { + const result = FormatUtils.channelMention('987654321098765432'); + expect(result).toBe('<#987654321098765432>'); + }); + + it('should handle short ID', () => { + const result = FormatUtils.channelMention('123'); + expect(result).toBe('<#123>'); + }); + + it('should handle empty string', () => { + const result = FormatUtils.channelMention(''); + expect(result).toBe('<#>'); + }); + }); + + describe('userMention', () => { + it('should format user mention correctly', () => { + const result = FormatUtils.userMention('123456789012345678'); + expect(result).toBe('<@!123456789012345678>'); + }); + + it('should handle different user IDs', () => { + const result = FormatUtils.userMention('987654321098765432'); + expect(result).toBe('<@!987654321098765432>'); + }); + + it('should handle short ID', () => { + const result = FormatUtils.userMention('123'); + expect(result).toBe('<@!123>'); + }); + + it('should handle empty string', () => { + const result = FormatUtils.userMention(''); + expect(result).toBe('<@!>'); + }); + }); + + describe('duration', () => { + it('should format seconds correctly', () => { + const result = FormatUtils.duration(5000, Locale.EnglishUS); + expect(result).toContain('5'); + expect(result.toLowerCase()).toContain('second'); + }); + + it('should format minutes correctly', () => { + const result = FormatUtils.duration(120000, Locale.EnglishUS); + expect(result).toContain('2'); + expect(result.toLowerCase()).toContain('minute'); + }); + + it('should format hours correctly', () => { + const result = FormatUtils.duration(3600000, Locale.EnglishUS); + expect(result).toContain('1'); + expect(result.toLowerCase()).toContain('hour'); + }); + + it('should format days correctly', () => { + const result = FormatUtils.duration(86400000, Locale.EnglishUS); + expect(result).toContain('1'); + expect(result.toLowerCase()).toContain('day'); + }); + + it('should format mixed duration correctly', () => { + // 1 hour, 30 minutes, 45 seconds + const result = FormatUtils.duration(5445000, Locale.EnglishUS); + expect(result).toBeTruthy(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle zero milliseconds', () => { + const result = FormatUtils.duration(0, Locale.EnglishUS); + // Zero duration returns empty string from luxon + expect(result).toBe(''); + }); + + it('should handle very large durations', () => { + // 365 days + const result = FormatUtils.duration(31536000000, Locale.EnglishUS); + expect(result).toBeTruthy(); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle different locales', () => { + const resultEN = FormatUtils.duration(5000, Locale.EnglishUS); + const resultES = FormatUtils.duration(5000, Locale.SpanishES); + expect(resultEN).toBeTruthy(); + expect(resultES).toBeTruthy(); + // Note: The actual format may differ between locales + }); + }); + + describe('fileSize', () => { + it('should format bytes correctly', () => { + const result = FormatUtils.fileSize(512); + expect(result).toContain('512'); + expect(result).toContain('B'); + }); + + it('should format kilobytes correctly', () => { + const result = FormatUtils.fileSize(1024); + expect(result).toContain('kB'); + }); + + it('should format megabytes correctly', () => { + const result = FormatUtils.fileSize(1048576); + expect(result).toContain('MB'); + }); + + it('should format gigabytes correctly', () => { + const result = FormatUtils.fileSize(1073741824); + expect(result).toContain('GB'); + }); + + it('should handle zero bytes', () => { + const result = FormatUtils.fileSize(0); + expect(result).toContain('0'); + expect(result).toContain('B'); + }); + + it('should handle large file sizes', () => { + const result = FormatUtils.fileSize(1099511627776); // 1 TB + expect(result).toContain('TB'); + }); + + it('should round to 2 decimal places', () => { + const result = FormatUtils.fileSize(1536); // 1.5 KB + expect(result).toBeTruthy(); + // filesize library with round: 2 should limit decimal places + }); + + it('should handle fractional bytes', () => { + const result = FormatUtils.fileSize(1536); + expect(result).toContain('kB'); + }); + }); +}); diff --git a/tests/unit/utils/math-utils.test.ts b/tests/unit/utils/math-utils.test.ts new file mode 100644 index 0000000..cd8676e --- /dev/null +++ b/tests/unit/utils/math-utils.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { MathUtils } from '../../../src/utils/math-utils.js'; + +describe('MathUtils', () => { + describe('sum', () => { + it('should return 0 for empty array', () => { + expect(MathUtils.sum([])).toBe(0); + }); + + it('should return the number itself for single element array', () => { + expect(MathUtils.sum([5])).toBe(5); + }); + + it('should sum positive numbers correctly', () => { + expect(MathUtils.sum([1, 2, 3, 4, 5])).toBe(15); + }); + + it('should sum negative numbers correctly', () => { + expect(MathUtils.sum([-1, -2, -3])).toBe(-6); + }); + + it('should sum mixed positive and negative numbers', () => { + expect(MathUtils.sum([10, -5, 3, -2])).toBe(6); + }); + + it('should handle decimal numbers', () => { + expect(MathUtils.sum([1.5, 2.5, 3.0])).toBe(7); + }); + + it('should handle zero values', () => { + expect(MathUtils.sum([0, 0, 0])).toBe(0); + }); + }); + + describe('clamp', () => { + it('should return min when input is below min', () => { + expect(MathUtils.clamp(5, 10, 20)).toBe(10); + }); + + it('should return max when input is above max', () => { + expect(MathUtils.clamp(25, 10, 20)).toBe(20); + }); + + it('should return input when within range', () => { + expect(MathUtils.clamp(15, 10, 20)).toBe(15); + }); + + it('should return min when input equals min', () => { + expect(MathUtils.clamp(10, 10, 20)).toBe(10); + }); + + it('should return max when input equals max', () => { + expect(MathUtils.clamp(20, 10, 20)).toBe(20); + }); + + it('should handle negative ranges', () => { + expect(MathUtils.clamp(-5, -10, -1)).toBe(-5); + expect(MathUtils.clamp(-15, -10, -1)).toBe(-10); + expect(MathUtils.clamp(0, -10, -1)).toBe(-1); + }); + + it('should handle decimal numbers', () => { + expect(MathUtils.clamp(5.5, 1.0, 10.0)).toBe(5.5); + expect(MathUtils.clamp(0.5, 1.0, 10.0)).toBe(1.0); + }); + + it('should handle zero in range', () => { + expect(MathUtils.clamp(0, -5, 5)).toBe(0); + }); + }); + + describe('range', () => { + it('should generate range starting from 0', () => { + expect(MathUtils.range(0, 5)).toEqual([0, 1, 2, 3, 4]); + }); + + it('should generate range starting from positive number', () => { + expect(MathUtils.range(5, 3)).toEqual([5, 6, 7]); + }); + + it('should generate range starting from negative number', () => { + expect(MathUtils.range(-3, 4)).toEqual([-3, -2, -1, 0]); + }); + + it('should return empty array for size 0', () => { + expect(MathUtils.range(0, 0)).toEqual([]); + }); + + it('should generate single element for size 1', () => { + expect(MathUtils.range(10, 1)).toEqual([10]); + }); + + it('should generate large ranges', () => { + const result = MathUtils.range(0, 100); + expect(result).toHaveLength(100); + expect(result[0]).toBe(0); + expect(result[99]).toBe(99); + }); + }); + + describe('ceilToMultiple', () => { + it('should round up to nearest multiple', () => { + expect(MathUtils.ceilToMultiple(13, 5)).toBe(15); + }); + + it('should return same value if already multiple', () => { + expect(MathUtils.ceilToMultiple(15, 5)).toBe(15); + }); + + it('should handle multiple of 1', () => { + expect(MathUtils.ceilToMultiple(13.7, 1)).toBe(14); + }); + + it('should handle multiple of 10', () => { + expect(MathUtils.ceilToMultiple(23, 10)).toBe(30); + expect(MathUtils.ceilToMultiple(20, 10)).toBe(20); + }); + + it('should handle decimal inputs', () => { + expect(MathUtils.ceilToMultiple(12.3, 5)).toBe(15); + }); + + it('should handle zero input', () => { + expect(MathUtils.ceilToMultiple(0, 5)).toBe(0); + }); + + it('should handle large multiples', () => { + expect(MathUtils.ceilToMultiple(150, 100)).toBe(200); + expect(MathUtils.ceilToMultiple(100, 100)).toBe(100); + }); + + it('should handle small multiples', () => { + expect(MathUtils.ceilToMultiple(1.1, 0.5)).toBe(1.5); + }); + }); +}); diff --git a/tests/unit/utils/random-utils.test.ts b/tests/unit/utils/random-utils.test.ts new file mode 100644 index 0000000..bb1b478 --- /dev/null +++ b/tests/unit/utils/random-utils.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; + +import { RandomUtils } from '../../../src/utils/random-utils.js'; + +describe('RandomUtils', () => { + describe('intFromInterval', () => { + it('should return a number within the specified range', () => { + for (let i = 0; i < 100; i++) { + const result = RandomUtils.intFromInterval(1, 10); + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(10); + } + }); + + it('should return min when min equals max', () => { + const result = RandomUtils.intFromInterval(5, 5); + expect(result).toBe(5); + }); + + it('should return integer values', () => { + for (let i = 0; i < 50; i++) { + const result = RandomUtils.intFromInterval(0, 100); + expect(Number.isInteger(result)).toBe(true); + } + }); + + it('should handle negative ranges', () => { + for (let i = 0; i < 50; i++) { + const result = RandomUtils.intFromInterval(-10, -1); + expect(result).toBeGreaterThanOrEqual(-10); + expect(result).toBeLessThanOrEqual(-1); + } + }); + + it('should handle ranges crossing zero', () => { + for (let i = 0; i < 50; i++) { + const result = RandomUtils.intFromInterval(-5, 5); + expect(result).toBeGreaterThanOrEqual(-5); + expect(result).toBeLessThanOrEqual(5); + } + }); + + it('should handle large ranges', () => { + for (let i = 0; i < 50; i++) { + const result = RandomUtils.intFromInterval(0, 1000000); + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(1000000); + } + }); + + it('should produce different values over multiple calls', () => { + const results = new Set(); + for (let i = 0; i < 100; i++) { + results.add(RandomUtils.intFromInterval(1, 100)); + } + // With 100 calls in range 1-100, we should get multiple different values + expect(results.size).toBeGreaterThan(10); + }); + + it('should be able to return min value', () => { + let foundMin = false; + for (let i = 0; i < 100; i++) { + if (RandomUtils.intFromInterval(1, 10) === 1) { + foundMin = true; + break; + } + } + expect(foundMin).toBe(true); + }); + + it('should be able to return max value', () => { + let foundMax = false; + for (let i = 0; i < 100; i++) { + if (RandomUtils.intFromInterval(1, 10) === 10) { + foundMax = true; + break; + } + } + expect(foundMax).toBe(true); + }); + }); + + describe('shuffle', () => { + it('should return an array with the same length', () => { + const input = [1, 2, 3, 4, 5]; + const result = RandomUtils.shuffle([...input]); + expect(result).toHaveLength(input.length); + }); + + it('should contain all original elements', () => { + const input = [1, 2, 3, 4, 5]; + const result = RandomUtils.shuffle([...input]); + expect(result.sort()).toEqual(input.sort()); + }); + + it('should handle empty array', () => { + const result = RandomUtils.shuffle([]); + expect(result).toEqual([]); + }); + + it('should handle single element array', () => { + const result = RandomUtils.shuffle([1]); + expect(result).toEqual([1]); + }); + + it('should handle two element array', () => { + const input = [1, 2]; + const result = RandomUtils.shuffle([...input]); + expect(result).toHaveLength(2); + expect(result).toContain(1); + expect(result).toContain(2); + }); + + it('should mutate the original array', () => { + const input = [1, 2, 3, 4, 5]; + const original = input; + RandomUtils.shuffle(input); + expect(input).toBe(original); + }); + + it('should handle arrays with duplicate values', () => { + const input = [1, 1, 2, 2, 3]; + const result = RandomUtils.shuffle([...input]); + expect(result.sort()).toEqual(input.sort()); + }); + + it('should handle arrays with different types', () => { + const input = ['a', 'b', 'c', 1, 2, 3]; + const result = RandomUtils.shuffle([...input]); + expect(result).toHaveLength(6); + expect(result).toContain('a'); + expect(result).toContain(1); + }); + + it('should produce different orderings over multiple calls', () => { + const input = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const results = new Set(); + + for (let i = 0; i < 50; i++) { + const shuffled = RandomUtils.shuffle([...input]); + results.add(JSON.stringify(shuffled)); + } + + // With 50 shuffles of 10 elements, we should get multiple different orderings + expect(results.size).toBeGreaterThan(20); + }); + + it('should handle arrays with objects', () => { + const input = [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + { id: 3, name: 'c' }, + ]; + const result = RandomUtils.shuffle([...input]); + expect(result).toHaveLength(3); + expect(result.some(item => item.id === 1)).toBe(true); + expect(result.some(item => item.id === 2)).toBe(true); + expect(result.some(item => item.id === 3)).toBe(true); + }); + + it('should use Fisher-Yates algorithm (statistical test)', () => { + // Test that all positions have roughly equal probability + const input = [1, 2, 3]; + const positionCounts = [ + { 1: 0, 2: 0, 3: 0 }, + { 1: 0, 2: 0, 3: 0 }, + { 1: 0, 2: 0, 3: 0 }, + ]; + + const iterations = 300; + for (let i = 0; i < iterations; i++) { + const shuffled = RandomUtils.shuffle([...input]); + shuffled.forEach((value, index) => { + positionCounts[index][value]++; + }); + } + + // Each number should appear in each position roughly 1/3 of the time + // We'll use a loose bound (between 20% and 47%) to account for randomness + positionCounts.forEach(position => { + Object.values(position).forEach(count => { + expect(count).toBeGreaterThan(iterations * 0.2); + expect(count).toBeLessThan(iterations * 0.47); + }); + }); + }); + }); +}); diff --git a/tests/unit/utils/regex-utils.test.ts b/tests/unit/utils/regex-utils.test.ts new file mode 100644 index 0000000..defe185 --- /dev/null +++ b/tests/unit/utils/regex-utils.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from 'vitest'; + +import { RegexUtils } from '../../../src/utils/regex-utils.js'; + +describe('RegexUtils', () => { + describe('regex', () => { + it('should parse simple regex pattern', () => { + const result = RegexUtils.regex('/test/'); + expect(result).toBeInstanceOf(RegExp); + expect(result?.source).toBe('test'); + expect(result?.flags).toBe(''); + }); + + it('should parse regex with flags', () => { + const result = RegexUtils.regex('/test/gi'); + expect(result).toBeInstanceOf(RegExp); + expect(result?.source).toBe('test'); + expect(result?.flags).toBe('gi'); + }); + + it('should parse regex with multiple flags', () => { + const result = RegexUtils.regex('/hello world/gim'); + expect(result).toBeInstanceOf(RegExp); + expect(result?.source).toBe('hello world'); + expect(result?.flags).toBe('gim'); + }); + + it('should parse complex regex pattern', () => { + const result = RegexUtils.regex('/\\d{3}-\\d{4}/'); + expect(result).toBeInstanceOf(RegExp); + expect(result?.source).toBe('\\d{3}-\\d{4}'); + }); + + it('should return undefined for invalid pattern (no slashes)', () => { + const result = RegexUtils.regex('test'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for pattern with only one slash', () => { + const result = RegexUtils.regex('/test'); + expect(result).toBeUndefined(); + }); + + it('should handle empty pattern', () => { + const result = RegexUtils.regex('//'); + expect(result).toBeInstanceOf(RegExp); + expect(result?.source).toBe('(?:)'); + }); + + it('should handle pattern with special characters', () => { + const result = RegexUtils.regex('/[a-z]+@[a-z]+\\.[a-z]+/i'); + expect(result).toBeInstanceOf(RegExp); + expect(result?.flags).toBe('i'); + }); + + it('should handle pattern with forward slashes in it', () => { + const result = RegexUtils.regex('/http:\\/\\/example\\.com/'); + expect(result).toBeInstanceOf(RegExp); + expect(result?.source).toBe('http:\\/\\/example\\.com'); + }); + }); + + describe('escapeRegex', () => { + it('should escape special regex characters', () => { + const result = RegexUtils.escapeRegex('test.*'); + expect(result).toBe('test\\.\\*'); + }); + + it('should escape brackets', () => { + const result = RegexUtils.escapeRegex('[test]'); + expect(result).toBe('\\[test\\]'); + }); + + it('should escape parentheses', () => { + const result = RegexUtils.escapeRegex('(test)'); + expect(result).toBe('\\(test\\)'); + }); + + it('should escape curly braces', () => { + const result = RegexUtils.escapeRegex('{test}'); + expect(result).toBe('\\{test\\}'); + }); + + it('should escape plus and asterisk', () => { + const result = RegexUtils.escapeRegex('test+*'); + expect(result).toBe('test\\+\\*'); + }); + + it('should escape question mark and dot', () => { + const result = RegexUtils.escapeRegex('test?.com'); + expect(result).toBe('test\\?\\.com'); + }); + + it('should escape caret and dollar', () => { + const result = RegexUtils.escapeRegex('^test$'); + expect(result).toBe('\\^test\\$'); + }); + + it('should escape pipe and backslash', () => { + const result = RegexUtils.escapeRegex('test|\\path'); + expect(result).toBe('test\\|\\\\path'); + }); + + it('should escape hyphen', () => { + const result = RegexUtils.escapeRegex('test-case'); + expect(result).toBe('test\\-case'); + }); + + it('should escape hash symbol', () => { + const result = RegexUtils.escapeRegex('#hashtag'); + expect(result).toBe('\\#hashtag'); + }); + + it('should escape whitespace', () => { + const result = RegexUtils.escapeRegex('hello world'); + expect(result).toBe('hello\\ world'); + }); + + it('should handle plain text without special chars', () => { + const result = RegexUtils.escapeRegex('hello'); + expect(result).toBe('hello'); + }); + + it('should handle empty string', () => { + const result = RegexUtils.escapeRegex(''); + expect(result).toBe(''); + }); + + it('should escape all special chars together', () => { + const result = RegexUtils.escapeRegex('[test.*+?^$]'); + expect(result).toBe('\\[test\\.\\*\\+\\?\\^\\$\\]'); + }); + + it('should handle undefined input', () => { + const result = RegexUtils.escapeRegex(undefined as any); + expect(result).toBeUndefined(); + }); + + it('should handle null input', () => { + const result = RegexUtils.escapeRegex(null as any); + expect(result).toBeUndefined(); + }); + }); + + describe('discordId', () => { + it('should extract valid Discord ID (17 digits)', () => { + const result = RegexUtils.discordId('12345678901234567'); + expect(result).toBe('12345678901234567'); + }); + + it('should extract valid Discord ID (18 digits)', () => { + const result = RegexUtils.discordId('123456789012345678'); + expect(result).toBe('123456789012345678'); + }); + + it('should extract valid Discord ID (19 digits)', () => { + const result = RegexUtils.discordId('1234567890123456789'); + expect(result).toBe('1234567890123456789'); + }); + + it('should extract valid Discord ID (20 digits)', () => { + const result = RegexUtils.discordId('12345678901234567890'); + expect(result).toBe('12345678901234567890'); + }); + + it('should extract ID from text', () => { + const result = RegexUtils.discordId('User ID: 123456789012345678'); + expect(result).toBe('123456789012345678'); + }); + + it('should extract ID from Discord mention format', () => { + const result = RegexUtils.discordId('<@!123456789012345678>'); + expect(result).toBe('123456789012345678'); + }); + + it('should extract ID from channel mention', () => { + const result = RegexUtils.discordId('<#123456789012345678>'); + expect(result).toBe('123456789012345678'); + }); + + it('should return undefined for short number (16 digits)', () => { + const result = RegexUtils.discordId('1234567890123456'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for long number (21 digits)', () => { + const result = RegexUtils.discordId('123456789012345678901'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for non-numeric text', () => { + const result = RegexUtils.discordId('not a discord id'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + const result = RegexUtils.discordId(''); + expect(result).toBeUndefined(); + }); + + it('should handle undefined input', () => { + const result = RegexUtils.discordId(undefined as any); + expect(result).toBeUndefined(); + }); + + it('should handle null input', () => { + const result = RegexUtils.discordId(null as any); + expect(result).toBeUndefined(); + }); + + it('should extract first ID when multiple are present', () => { + const result = RegexUtils.discordId('123456789012345678 and 987654321098765432'); + expect(result).toBe('123456789012345678'); + }); + }); + + describe('tag', () => { + it('should parse valid Discord tag', () => { + const result = RegexUtils.tag('username#1234'); + expect(result).toEqual({ + tag: 'username#1234', + username: 'username', + discriminator: '1234', + }); + }); + + it('should parse tag with complex username', () => { + const result = RegexUtils.tag('User Name 123#5678'); + expect(result).toEqual({ + tag: 'User Name 123#5678', + username: 'User Name 123', + discriminator: '5678', + }); + }); + + it('should parse tag with special characters in username', () => { + const result = RegexUtils.tag('user_name-123#9999'); + expect(result).toEqual({ + tag: 'user_name-123#9999', + username: 'user_name-123', + discriminator: '9999', + }); + }); + + it('should parse tag with minimum discriminator (0000)', () => { + const result = RegexUtils.tag('user#0000'); + expect(result).toEqual({ + tag: 'user#0000', + username: 'user', + discriminator: '0000', + }); + }); + + it('should return undefined for invalid discriminator (3 digits)', () => { + const result = RegexUtils.tag('username#123'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for invalid discriminator (5 digits)', () => { + const result = RegexUtils.tag('username#12345'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for missing discriminator', () => { + const result = RegexUtils.tag('username'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for missing username', () => { + const result = RegexUtils.tag('#1234'); + expect(result).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + const result = RegexUtils.tag(''); + expect(result).toBeUndefined(); + }); + + it('should handle tag embedded in text', () => { + const result = RegexUtils.tag('The user is TestUser#1234 in the server'); + // Note: The regex matches everything before #, including leading text + expect(result).toEqual({ + tag: 'The user is TestUser#1234', + username: 'The user is TestUser', + discriminator: '1234', + }); + }); + + it('should parse tag with single character username', () => { + const result = RegexUtils.tag('a#1234'); + expect(result).toEqual({ + tag: 'a#1234', + username: 'a', + discriminator: '1234', + }); + }); + + it('should return undefined for letters in discriminator', () => { + const result = RegexUtils.tag('username#abcd'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/utils/string-utils.test.ts b/tests/unit/utils/string-utils.test.ts new file mode 100644 index 0000000..8c89045 --- /dev/null +++ b/tests/unit/utils/string-utils.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import { StringUtils } from '../../../src/utils/string-utils.js'; + +describe('StringUtils', () => { + describe('truncate', () => { + it('should return original string if shorter than length', () => { + expect(StringUtils.truncate('hello', 10)).toBe('hello'); + }); + + it('should return original string if equal to length', () => { + expect(StringUtils.truncate('hello', 5)).toBe('hello'); + }); + + it('should truncate string if longer than length', () => { + expect(StringUtils.truncate('hello world', 5, false)).toBe('hello'); + }); + + it('should truncate with ellipsis when addEllipsis is true', () => { + expect(StringUtils.truncate('hello world', 8, true)).toBe('hello...'); + }); + + it('should handle exact length with ellipsis', () => { + expect(StringUtils.truncate('hello', 5, true)).toBe('hello'); + }); + + it('should truncate long string with ellipsis', () => { + expect(StringUtils.truncate('this is a very long string', 10, true)).toBe('this is...'); + }); + + it('should handle empty string', () => { + expect(StringUtils.truncate('', 5)).toBe(''); + }); + + it('should handle single character', () => { + expect(StringUtils.truncate('a', 1)).toBe('a'); + expect(StringUtils.truncate('ab', 1, false)).toBe('a'); + }); + + it('should handle minimum length with ellipsis', () => { + expect(StringUtils.truncate('hello world', 3, true)).toBe('...'); + }); + + it('should default addEllipsis to false', () => { + expect(StringUtils.truncate('hello world', 5)).toBe('hello'); + }); + + it('should handle unicode characters', () => { + // Note: Emoji characters like 👋 count as 2 characters in JavaScript + expect(StringUtils.truncate('hello 👋 world', 8, false)).toBe('hello 👋'); + }); + }); + + describe('escapeMarkdown', () => { + it('should escape asterisks', () => { + const result = StringUtils.escapeMarkdown('*bold*'); + expect(result).toContain('\\*'); + }); + + it('should escape underscores', () => { + const result = StringUtils.escapeMarkdown('_italic_'); + expect(result).toContain('\\_'); + }); + + it('should preserve custom Discord emojis', () => { + const emoji = '<:emojiName:123456789012345678>'; + const result = StringUtils.escapeMarkdown(emoji); + expect(result).toBe(emoji); + }); + + it('should preserve animated custom Discord emojis', () => { + const emoji = ''; + const result = StringUtils.escapeMarkdown(emoji); + expect(result).toBe(emoji); + }); + + it('should handle escaped characters in emoji names', () => { + const text = 'Text with <:emoji\\_name:123456789012345678> inside'; + const result = StringUtils.escapeMarkdown(text); + // Should unescape the emoji name but keep other escapes + expect(result).toContain('<:emoji_name:123456789012345678>'); + }); + + it('should handle text without markdown', () => { + expect(StringUtils.escapeMarkdown('plain text')).toBe('plain text'); + }); + + it('should handle empty string', () => { + expect(StringUtils.escapeMarkdown('')).toBe(''); + }); + + it('should handle mixed markdown and emojis', () => { + const text = '*bold* <:smile:123456789012345678> _italic_'; + const result = StringUtils.escapeMarkdown(text); + expect(result).toContain('<:smile:123456789012345678>'); + expect(result).toContain('\\*'); + // Note: Discord.js escapeMarkdown behavior with underscores may vary + // Just ensure the emoji is preserved + }); + }); + + describe('stripMarkdown', () => { + it('should remove bold markdown', () => { + const result = StringUtils.stripMarkdown('**bold text**'); + expect(result).not.toContain('**'); + expect(result).toContain('bold text'); + }); + + it('should remove italic markdown', () => { + const result = StringUtils.stripMarkdown('*italic text*'); + expect(result).not.toContain('*'); + expect(result).toContain('italic text'); + }); + + it('should remove underline markdown', () => { + const result = StringUtils.stripMarkdown('_underline text_'); + expect(result).not.toContain('_'); + expect(result).toContain('underline text'); + }); + + it('should remove headers', () => { + const result = StringUtils.stripMarkdown('# Header'); + expect(result).not.toContain('#'); + expect(result).toContain('Header'); + }); + + it('should remove links', () => { + const result = StringUtils.stripMarkdown('[link text](https://example.com)'); + expect(result).toContain('link text'); + expect(result).not.toContain('['); + expect(result).not.toContain(']'); + }); + + it('should remove code blocks', () => { + const result = StringUtils.stripMarkdown('`code`'); + expect(result).not.toContain('`'); + expect(result).toContain('code'); + }); + + it('should handle plain text', () => { + expect(StringUtils.stripMarkdown('plain text')).toBe('plain text'); + }); + + it('should handle empty string', () => { + expect(StringUtils.stripMarkdown('')).toBe(''); + }); + + it('should remove multiple markdown elements', () => { + const text = '**bold** and *italic* with [link](url)'; + const result = StringUtils.stripMarkdown(text); + expect(result).not.toContain('**'); + expect(result).not.toContain('*'); + expect(result).not.toContain('['); + expect(result).toContain('bold'); + expect(result).toContain('italic'); + expect(result).toContain('link'); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 32e7f00..3dc4a3a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "forceConsistentCasingInFileNames": true, "jsx": "react-jsx" }, - "exclude": ["dist", "node_modules", "tests"] + "exclude": ["dist", "node_modules", "tests", "vitest.config.ts"] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..da73a18 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["tests/**/*.ts", "src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..26ddaaa --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,31 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.config.ts', + '**/*.config.js', + '**/types.ts', + '**/enums/', + ], + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});