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
This commit is contained in:
Justin Edmund 2025-09-19 23:06:59 -07:00
parent ece44a54a3
commit 99e58d0b3c
3 changed files with 491 additions and 0 deletions

View file

@ -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()
})
})
})

View file

@ -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'

View file

@ -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
* <script>
* import { createSearchResource } from '$lib/api/adapters/resources'
*
* const search = createSearchResource({
* debounceMs: 300,
* initialParams: {
* locale: 'en'
* }
* })
*
* let query = $state('')
*
* // Reactive search on query change
* $effect(() => {
* if (query) {
* search.searchWeapons({ query })
* }
* })
* </script>
*
* <input bind:value={query} placeholder="Search weapons..." />
*
* {#if search.weapons.loading}
* <p>Searching...</p>
* {:else if search.weapons.error}
* <p>Error: {search.weapons.error.message}</p>
* {:else if search.weapons.data}
* <ul>
* {#each search.weapons.data.results as result}
* <li>{result.name.en}</li>
* {/each}
* </ul>
* {/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<SearchState>({ loading: false })
weapons = $state<SearchState>({ loading: false })
characters = $state<SearchState>({ loading: false })
summons = $state<SearchState>({ loading: false })
// Track active requests for cancellation
private activeRequests = new Map<string, AbortController>()
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)
}