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:
parent
ece44a54a3
commit
99e58d0b3c
3 changed files with 491 additions and 0 deletions
252
src/lib/api/adapters/resources/__tests__/search.resource.test.ts
Normal file
252
src/lib/api/adapters/resources/__tests__/search.resource.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
15
src/lib/api/adapters/resources/index.ts
Normal file
15
src/lib/api/adapters/resources/index.ts
Normal 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'
|
||||
224
src/lib/api/adapters/resources/search.resource.svelte.ts
Normal file
224
src/lib/api/adapters/resources/search.resource.svelte.ts
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue