From 99e58d0b3c27ce03e1ddfc49f5f04a8ca86b47ff Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 19 Sep 2025 23:06:59 -0700 Subject: [PATCH] feat: add reactive search resource using Svelte 5 runes - Create SearchResource class with reactive state management - Implement debounced search using Runed utilities - Support concurrent searches for different entity types - Add request cancellation and state management - Include tests for resource functionality --- .../__tests__/search.resource.test.ts | 252 ++++++++++++++++++ src/lib/api/adapters/resources/index.ts | 15 ++ .../resources/search.resource.svelte.ts | 224 ++++++++++++++++ 3 files changed, 491 insertions(+) create mode 100644 src/lib/api/adapters/resources/__tests__/search.resource.test.ts create mode 100644 src/lib/api/adapters/resources/index.ts create mode 100644 src/lib/api/adapters/resources/search.resource.svelte.ts diff --git a/src/lib/api/adapters/resources/__tests__/search.resource.test.ts b/src/lib/api/adapters/resources/__tests__/search.resource.test.ts new file mode 100644 index 00000000..332bbfa5 --- /dev/null +++ b/src/lib/api/adapters/resources/__tests__/search.resource.test.ts @@ -0,0 +1,252 @@ +/** + * Tests for SearchResource + * + * These tests verify the reactive search resource functionality + * including debouncing, state management, and cancellation. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { SearchResource } from '../search.resource.svelte' +import { SearchAdapter } from '../../search.adapter' +import type { SearchResponse } from '../../search.adapter' + +describe('SearchResource', () => { + let resource: SearchResource + let mockAdapter: SearchAdapter + let originalFetch: typeof global.fetch + + beforeEach(() => { + originalFetch = global.fetch + + // Create mock adapter + mockAdapter = new SearchAdapter({ + baseURL: 'https://api.example.com' + }) + + // Create resource with short debounce for testing + resource = new SearchResource({ + adapter: mockAdapter, + debounceMs: 10, + initialParams: { locale: 'en' } + }) + }) + + afterEach(() => { + global.fetch = originalFetch + resource.cancelAll() + vi.clearAllTimers() + }) + + describe('initialization', () => { + it('should initialize with empty state', () => { + expect(resource.weapons.loading).toBe(false) + expect(resource.weapons.data).toBeUndefined() + expect(resource.weapons.error).toBeUndefined() + + expect(resource.characters.loading).toBe(false) + expect(resource.summons.loading).toBe(false) + expect(resource.all.loading).toBe(false) + }) + + it('should accept initial parameters', () => { + const customResource = new SearchResource({ + initialParams: { + locale: 'ja', + page: 2 + } + }) + + // We'll verify these are used in the search tests + expect(customResource).toBeDefined() + }) + }) + + describe('search operations', () => { + it('should search weapons with debouncing', async () => { + const mockResponse: SearchResponse = { + results: [ + { + id: '1', + granblueId: 'weapon_1', + name: { en: 'Test Weapon' }, + element: 1, + rarity: 5, + searchableType: 'Weapon' + } + ], + total: 1 + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + id: '1', + granblue_id: 'weapon_1', + name: { en: 'Test Weapon' }, + element: 1, + rarity: 5, + searchable_type: 'Weapon' + } + ], + total: 1 + }) + }) + + // Start search + resource.searchWeapons({ query: 'test' }) + + // Should be loading immediately + expect(resource.weapons.loading).toBe(true) + + // Wait for debounce + response + await new Promise(resolve => setTimeout(resolve, 50)) + + // Should have results + expect(resource.weapons.loading).toBe(false) + expect(resource.weapons.data).toEqual(mockResponse) + expect(resource.weapons.error).toBeUndefined() + + // Verify API was called with merged params + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + locale: 'en', // From initialParams + page: 1, + query: 'test' + }) + }) + ) + }) + + it('should handle search errors', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Server Error', + json: async () => ({ error: 'Internal error' }) + }) + + resource.searchCharacters({ query: 'error' }) + + // Wait for debounce + response + await new Promise(resolve => setTimeout(resolve, 50)) + + expect(resource.characters.loading).toBe(false) + expect(resource.characters.data).toBeUndefined() + expect(resource.characters.error).toMatchObject({ + code: 'SERVER_ERROR', + status: 500 + }) + }) + + it('should cancel previous search when new one starts', async () => { + let callCount = 0 + global.fetch = vi.fn().mockImplementation(() => { + callCount++ + return new Promise(resolve => { + setTimeout(() => { + resolve({ + ok: true, + json: async () => ({ results: [] }) + }) + }, 100) // Slow response + }) + }) + + // Start first search + resource.searchAll({ query: 'first' }) + + // Start second search before first completes + await new Promise(resolve => setTimeout(resolve, 20)) + resource.searchAll({ query: 'second' }) + + // Wait for completion + await new Promise(resolve => setTimeout(resolve, 150)) + + // Should have made 2 calls but only the second one's result should be set + expect(callCount).toBe(2) + }) + }) + + describe('state management', () => { + it('should clear specific search type', () => { + // Set some mock data + resource.weapons = { + loading: false, + data: { results: [] }, + error: undefined + } + + resource.characters = { + loading: false, + data: { results: [] }, + error: undefined + } + + // Clear weapons only + resource.clear('weapons') + + expect(resource.weapons.data).toBeUndefined() + expect(resource.weapons.loading).toBe(false) + expect(resource.characters.data).toBeDefined() // Should remain + }) + + it('should clear all search results', () => { + // Set mock data for all types + resource.weapons = { loading: false, data: { results: [] } } + resource.characters = { loading: false, data: { results: [] } } + resource.summons = { loading: false, data: { results: [] } } + resource.all = { loading: false, data: { results: [] } } + + // Clear all + resource.clearAll() + + expect(resource.weapons.data).toBeUndefined() + expect(resource.characters.data).toBeUndefined() + expect(resource.summons.data).toBeUndefined() + expect(resource.all.data).toBeUndefined() + }) + + it('should update base parameters', () => { + resource.updateBaseParams({ locale: 'ja', per: 50 }) + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }) + }) + + resource.searchWeapons({ query: 'test' }) + + // Wait for debounce + setTimeout(() => { + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('"locale":"ja"') + }) + ) + }, 50) + }) + }) + + describe('cancellation', () => { + it('should cancel specific search type', () => { + const cancelSpy = vi.spyOn(resource, 'cancelSearch') + + resource.clear('weapons') + + expect(cancelSpy).toHaveBeenCalledWith('weapons') + }) + + it('should cancel all searches', () => { + const cancelAllSpy = vi.spyOn(resource, 'cancelAll') + + resource.clearAll() + + expect(cancelAllSpy).toHaveBeenCalled() + }) + }) +}) \ No newline at end of file diff --git a/src/lib/api/adapters/resources/index.ts b/src/lib/api/adapters/resources/index.ts new file mode 100644 index 00000000..48b56b44 --- /dev/null +++ b/src/lib/api/adapters/resources/index.ts @@ -0,0 +1,15 @@ +/** + * Reactive Resources using Svelte 5 Runes + * + * This module exports reactive resources that provide + * state management for API operations. + * + * @module adapters/resources + */ + +export { SearchResource, createSearchResource } from './search.resource.svelte' +export type { SearchResourceOptions } from './search.resource.svelte' + +// Future resources will be added here +// export { PartyResource, createPartyResource } from './party.resource.svelte' +// export { GridResource, createGridResource } from './grid.resource.svelte' \ No newline at end of file diff --git a/src/lib/api/adapters/resources/search.resource.svelte.ts b/src/lib/api/adapters/resources/search.resource.svelte.ts new file mode 100644 index 00000000..36545ccb --- /dev/null +++ b/src/lib/api/adapters/resources/search.resource.svelte.ts @@ -0,0 +1,224 @@ +/** + * Reactive Search Resource using Svelte 5 Runes and Runed + * + * Provides reactive state management for search operations with + * automatic loading states, error handling, and debouncing. + * + * @module adapters/resources/search + */ + +import { debounced } from 'runed' +import { SearchAdapter, type SearchParams, type SearchResponse } from '../search.adapter' +import type { AdapterError } from '../types' + +/** + * Search resource configuration options + */ +export interface SearchResourceOptions { + /** Search adapter instance to use */ + adapter?: SearchAdapter + /** Debounce delay in milliseconds for search queries */ + debounceMs?: number + /** Initial search parameters */ + initialParams?: SearchParams +} + +/** + * Search result state for a specific entity type + */ +interface SearchState { + data?: SearchResponse + loading: boolean + error?: AdapterError +} + +/** + * Creates a reactive search resource for entity searching + * This is a Svelte 5 universal reactive state (works in both components and modules) + * + * @example + * ```svelte + * + * + * + * + * {#if search.weapons.loading} + *

Searching...

+ * {:else if search.weapons.error} + *

Error: {search.weapons.error.message}

+ * {:else if search.weapons.data} + * + * {/if} + * ``` + */ +export class SearchResource { + // Private adapter instance + private adapter: SearchAdapter + + // Base parameters for all searches + private baseParams: SearchParams + + // Debounce delay + private debounceMs: number + + // Reactive state for each search type + all = $state({ loading: false }) + weapons = $state({ loading: false }) + characters = $state({ loading: false }) + summons = $state({ loading: false }) + + // Track active requests for cancellation + private activeRequests = new Map() + + constructor(options: SearchResourceOptions = {}) { + this.adapter = options.adapter || new SearchAdapter() + this.debounceMs = options.debounceMs || 300 + this.baseParams = options.initialParams || {} + } + + /** + * Creates a debounced search function for a specific entity type + */ + private createDebouncedSearch( + type: 'all' | 'weapons' | 'characters' | 'summons' + ) { + const searchFn = async (params: SearchParams) => { + // Cancel any existing request for this type + this.cancelSearch(type) + + // Create new abort controller + const controller = new AbortController() + this.activeRequests.set(type, controller) + + // Update loading state + this[type] = { ...this[type], loading: true, error: undefined } + + try { + // Merge base params with provided params + const mergedParams = { ...this.baseParams, ...params } + + // Call appropriate adapter method + let response: SearchResponse + switch (type) { + case 'all': + response = await this.adapter.searchAll(mergedParams) + break + case 'weapons': + response = await this.adapter.searchWeapons(mergedParams) + break + case 'characters': + response = await this.adapter.searchCharacters(mergedParams) + break + case 'summons': + response = await this.adapter.searchSummons(mergedParams) + break + } + + // Update state with results + this[type] = { data: response, loading: false } + } catch (error: any) { + // Don't update state if request was cancelled + if (error.code !== 'CANCELLED') { + this[type] = { + ...this[type], + loading: false, + error: error as AdapterError + } + } + } finally { + this.activeRequests.delete(type) + } + } + + // Return debounced version + return debounced(searchFn, this.debounceMs) + } + + // Create debounced search methods + searchAll = this.createDebouncedSearch('all') + searchWeapons = this.createDebouncedSearch('weapons') + searchCharacters = this.createDebouncedSearch('characters') + searchSummons = this.createDebouncedSearch('summons') + + /** + * Cancels an active search request + */ + cancelSearch(type: 'all' | 'weapons' | 'characters' | 'summons') { + const controller = this.activeRequests.get(type) + if (controller) { + controller.abort() + this.activeRequests.delete(type) + } + } + + /** + * Cancels all active search requests + */ + cancelAll() { + this.activeRequests.forEach(controller => controller.abort()) + this.activeRequests.clear() + } + + /** + * Clears results for a specific search type + */ + clear(type: 'all' | 'weapons' | 'characters' | 'summons') { + this.cancelSearch(type) + this[type] = { loading: false } + } + + /** + * Clears all search results + */ + clearAll() { + this.cancelAll() + this.all = { loading: false } + this.weapons = { loading: false } + this.characters = { loading: false } + this.summons = { loading: false } + } + + /** + * Clears the adapter's cache + */ + clearCache() { + this.adapter.clearSearchCache() + } + + /** + * Updates base parameters for all searches + */ + updateBaseParams(params: SearchParams) { + this.baseParams = { ...this.baseParams, ...params } + } +} + +/** + * Factory function for creating search resources + * Provides a more functional API if preferred over class instantiation + */ +export function createSearchResource(options?: SearchResourceOptions): SearchResource { + return new SearchResource(options) +} \ No newline at end of file