diff --git a/aicontext/modules/core.md b/aicontext/modules/core.md index 1884e179..d06ac7e7 100644 --- a/aicontext/modules/core.md +++ b/aicontext/modules/core.md @@ -3,7 +3,9 @@ ## Features ### Busca unificada com cache de índice e ranking -A busca unificada em `src/core/utils/search-engine.ts` mantém em memória os dados ativos carregados dos providers, reaproveita índices Fuse por escopo de busca e reutiliza o ranking calculado para a mesma query. Para maximizar a performance com milhares de itens (ex: monstros), a busca ignora campos longos como `description` e `source`, e utiliza um `threshold` restrito de `0.15` para descartar matches distantes rapidamente. Além disso, a paginação é aplicada de forma "lazy" (antes do mapping) e o plugin de sugestões do TipTap possui um `debounce` de `150ms` para evitar sobrecarga da thread principal durante a digitação. `invalidateSearchCache()` limpa todos os caches para reconstrução. +A busca unificada mantém em memória os dados ativos carregados dos providers, reaproveita índices Fuse por escopo de busca e reutiliza o ranking calculado para a mesma query. Para maximizar a performance com milhares de itens (ex: monstros), a busca ignora campos longos como `description` e `source`, utiliza `threshold` restrito de `0.15` para descartar matches distantes rapidamente e aplica paginação de forma "lazy" antes do mapping. + +`src/core/utils/search-engine.ts` preserva a API compartilhada por rotas, hooks e serviços server-side. A lógica pura fica em `src/core/utils/search-core.ts`, permitindo que o frontend execute a busca de menções em `search.worker.ts` via `search-worker-client.ts`. O worker possui cache próprio por provider, filtros, índices e rankings, é aquecido por `useWarmSearchCache()` após um atraso curto no dashboard e é invalidado junto com `invalidateSearchCache()` no browser. Para menções, o worker usa providers já carregados e emite resultados parciais conforme endpoints ainda pendentes terminam, sem esperar o cache completo. Quando `Worker` não está disponível ou falha, a fachada client usa a busca principal como fallback. ### Geração de imagens com Gemini salva no bucket `POST /api/core/ai/image` executa a geração de imagem no servidor com um modelo Gemini compatível, extrai o primeiro `inlineData` retornado, salva o arquivo no bucket S3/MinIO do projeto e responde com `url`, `key` e `mimeType`. O endpoint aceita tanto prompt livre quanto o JSON completo de um formulário, transforma esse payload em um prompt especializado em estética oficial de Dungeons & Dragons com preferência por composição 1:1 e bloqueia uploads gerados acima de 5MB antes de persistir no bucket. diff --git a/aicontext/modules/rules.md b/aicontext/modules/rules.md index 4eeb4aa0..11c8125c 100644 --- a/aicontext/modules/rules.md +++ b/aicontext/modules/rules.md @@ -5,10 +5,11 @@ This module handles the D&D Reference Rules system, allowing administrators to m ## Key Features - **Reference Management**: CRUD operations for Rule entities. -- **Rich Text Editor**: Custom editor using Tiptap with image upload to S3, mention support, and table insertion. Mentions are enhanced with temporary badge styling (`decorationClass`). The system includes custom boundary detection (`findSuggestionMatch`) that keeps the temporary badge/query limited to the active mention word, including only the first existing word when `@` is inserted directly before text, and a smart escape mechanism using `ArrowRight` (injecting `\u200B`) to allow exiting the mention context seamlessly. +- **Rich Text Editor**: Custom editor using Tiptap with image upload to S3, mention support, and table insertion. Mentions are enhanced with temporary badge styling (`decorationClass`). The system includes custom boundary detection (`findSuggestionMatch`) that keeps the temporary badge/query limited to the active mention word, including only the first existing word when `@` is inserted directly before text, and a smart escape mechanism using `ArrowRight` (injecting `\u200B`) to allow exiting the mention context seamlessly. Mention suggestions use the unified search worker on the client so Fuse indexing/search does not block typing in the editor UI, and results are updated progressively as providers finish loading. - **Audit Logging**: Full traceability of changes via `AuditLogExtended`. - **Dashboard Integration**: Real-time stats on existing rules. - **Entity Preview (MentionContent)**: Renders rich HTML content including styled tables, images, dice values, and mention badges. +- **Mention Preview Safety**: Monster previews normalize incomplete API payloads before rendering so missing nested fields such as `attributes.wisdom`, skills, senses, or action arrays do not crash the client while hovering mention results. ## Data Models diff --git a/src/core/hooks/useWarmSearchCache.ts b/src/core/hooks/useWarmSearchCache.ts index 4e2a5b0b..1726c27c 100644 --- a/src/core/hooks/useWarmSearchCache.ts +++ b/src/core/hooks/useWarmSearchCache.ts @@ -1,5 +1,8 @@ import { useEffect } from "react" import { warmSearchCache } from "@/core/utils/search-engine" +import { warmSearchWorkerCache } from "@/core/utils/search-worker-client" + +const WARM_SEARCH_CACHE_DELAY_MS = 1500 /** * Pre-populates the unified search cache on mount so the first `@` mention @@ -7,6 +10,13 @@ import { warmSearchCache } from "@/core/utils/search-engine" */ export function useWarmSearchCache(): void { useEffect(() => { - warmSearchCache() + const timer = window.setTimeout(() => { + warmSearchCache() + warmSearchWorkerCache() + }, WARM_SEARCH_CACHE_DELAY_MS) + + return () => { + window.clearTimeout(timer) + } }, []) } diff --git a/src/core/utils/search-core.ts b/src/core/utils/search-core.ts new file mode 100644 index 00000000..4d28597b --- /dev/null +++ b/src/core/utils/search-core.ts @@ -0,0 +1,179 @@ +import Fuse, { type FuseResult } from "fuse.js" +import type { EntityType } from "@/lib/config/colors" + +export interface UnifiedEntity { + id: string + _id?: string + name: string + originalName?: string + label?: string + type: "Regra" | "Magia" | "Habilidade" | "Talento" | "Classe" | "Subclasse" | "Origem" | "Raça" | "Item" | "Monstro" + description?: string + source?: string + status: "active" | "inactive" + metadata?: UnifiedEntityMetadata + school?: string + circle?: number + saveAttribute?: string + component?: string[] + baseDice?: unknown + extraDicePerLevel?: unknown + rarity?: string + itemType?: string + price?: string + damageDice?: unknown + damageType?: string + ac?: number + acType?: string + armorType?: string + acBonus?: number + attributeUsed?: string + image?: string + isMagic?: boolean + traits?: unknown[] + properties?: unknown[] + additionalDamage?: unknown[] + mastery?: string + score?: number +} + +export interface UnifiedEntityMetadata extends Record { + parentClassId?: string + parentClassName?: string + subclassId?: string + subclassName?: string + subclassColor?: string +} + +export interface UnifiedSearchOptions { + specificEntityType?: EntityType + specificEntityTypes?: EntityType[] + itemTypes?: string[] + circles?: number[] + parentClassId?: string | null +} + +export type SearchableEntity = { name?: string; originalName?: string; label?: string; source?: string; description?: string } +type SearchResultItem = SearchableEntity & { + id?: { toString: () => string } | string + _id?: { toString: () => string } | string + toObject?: () => Record +} + +export const FUSE_OPTIONS = { + keys: [ + { name: "name", weight: 10 }, + { name: "originalName", weight: 8 }, + { name: "label", weight: 10 }, + ], + threshold: 0.15, + includeScore: true, + shouldSort: true, + minMatchCharLength: 2 +} + +export type FuzzyCacheEntry = { + fuse: Fuse + rankedResultsByQuery: Map[]> +} + +export function buildFilterCacheKey(options?: UnifiedSearchOptions): string | null { + if (!options) return null + + const entityTypes = options.specificEntityTypes?.length + ? [...options.specificEntityTypes].sort() + : options.specificEntityType + ? [options.specificEntityType] + : [] + + const itemTypes = options.itemTypes?.length ? [...options.itemTypes].sort() : [] + const circles = options.circles?.length ? [...options.circles].sort((left, right) => left - right) : [] + const parentClassId = options.parentClassId ?? null + + if (entityTypes.length === 0 && itemTypes.length === 0 && circles.length === 0 && parentClassId === null) { + return null + } + + return JSON.stringify({ + entityTypes, + itemTypes, + circles, + parentClassId, + }) +} + +export function createFuzzyCacheEntry(items: T[]): FuzzyCacheEntry { + return { + fuse: new Fuse(items, FUSE_OPTIONS), + rankedResultsByQuery: new Map(), + } +} + +export function filterEntitiesByOptions(items: UnifiedEntity[], options?: UnifiedSearchOptions): UnifiedEntity[] { + if (!options) return items + + const entityTypes = options.specificEntityTypes?.length + ? options.specificEntityTypes + : options.specificEntityType + ? [options.specificEntityType] + : null + + return items.filter((entity) => { + if (entityTypes && !entityTypes.includes(entity.type as EntityType)) return false + if (options.itemTypes?.length && entity.type === "Item" && !options.itemTypes.includes(entity.itemType ?? "")) return false + if (options.circles?.length && entity.type === "Magia" && !options.circles.includes(entity.circle ?? -1)) return false + if (options.parentClassId && entity.type === "Subclasse" && entity.metadata?.parentClassId !== options.parentClassId) return false + return true + }) +} + +function getRankedFuzzyResults( + cacheEntry: FuzzyCacheEntry, + query: string +): FuseResult[] { + const cachedResults = cacheEntry.rankedResultsByQuery.get(query) + if (cachedResults) return cachedResults + + const fuseResults = cacheEntry.fuse.search(query) + cacheEntry.rankedResultsByQuery.set(query, fuseResults) + return fuseResults +} + +export function applyFuzzySearchWithCache( + items: T[], + query: string, + cacheEntry: FuzzyCacheEntry, + limit?: number, + offset = 0 +): T[] { + if (!query.trim()) { + return items.slice(offset, limit ? offset + limit : undefined) + } + + const rawResults = getRankedFuzzyResults(cacheEntry, query) + const paginatedRawResults = limit + ? rawResults.slice(offset, offset + limit) + : rawResults.slice(offset) + + return paginatedRawResults.map((result) => { + const item = result.item as SearchResultItem + const baseItem = (typeof item.toObject === "function" ? item.toObject() : { ...result.item }) as Record & SearchResultItem + const id = baseItem._id?.toString() || baseItem.id?.toString() + + return { + ...baseItem, + id, + _id: id, + score: result.score + } as unknown as T + }) +} + +export function applyFuzzySearch( + items: T[], + query: string, + limit?: number, + offset = 0 +): T[] { + return applyFuzzySearchWithCache(items, query, createFuzzyCacheEntry(items), limit, offset) +} diff --git a/src/core/utils/search-engine.ts b/src/core/utils/search-engine.ts index 3cdb47c4..19d882ca 100644 --- a/src/core/utils/search-engine.ts +++ b/src/core/utils/search-engine.ts @@ -1,89 +1,29 @@ -import Fuse, { type FuseResult } from "fuse.js" import { ENTITY_PROVIDERS } from "@/lib/config/entities" -import type { EntityType } from "@/lib/config/colors" +import { + applyFuzzySearchWithCache, + buildFilterCacheKey, + createFuzzyCacheEntry, + filterEntitiesByOptions as filterEntities, + type FuzzyCacheEntry, + type SearchableEntity, + type UnifiedEntity, + type UnifiedSearchOptions, +} from "@/core/utils/search-core" + +export { + filterEntitiesByOptions, + type UnifiedEntity, + type UnifiedEntityMetadata, + type UnifiedSearchOptions, +} from "@/core/utils/search-core" /** * @fileoverview Central search engine for multi-entity lookups. * Includes Fuse.js fuzzy search with weighted scoring. */ -export interface UnifiedEntity { - id: string - _id?: string - name: string - originalName?: string - label?: string // For compatibility - type: "Regra" | "Magia" | "Habilidade" | "Talento" | "Classe" | "Subclasse" | "Origem" | "Raça" | "Item" | "Monstro" - description?: string - source?: string - status: "active" | "inactive" - metadata?: UnifiedEntityMetadata - school?: string - circle?: number - saveAttribute?: string - component?: string[] - baseDice?: unknown - extraDicePerLevel?: unknown - rarity?: string - itemType?: string - price?: string - damageDice?: unknown - damageType?: string - ac?: number - acType?: string - armorType?: string - acBonus?: number - attributeUsed?: string - image?: string - isMagic?: boolean - traits?: unknown[] - properties?: unknown[] - additionalDamage?: unknown[] - mastery?: string - score?: number // Added for weighted sorting visibility if needed -} - -export interface UnifiedEntityMetadata extends Record { - parentClassId?: string - parentClassName?: string - subclassId?: string - subclassName?: string - subclassColor?: string -} - -export interface UnifiedSearchOptions { - specificEntityType?: EntityType - specificEntityTypes?: EntityType[] - itemTypes?: string[] - circles?: number[] - parentClassId?: string | null -} - -type SearchableEntity = { name?: string; originalName?: string; label?: string; source?: string; description?: string } -type SearchResultItem = SearchableEntity & { - id?: { toString: () => string } | string - _id?: { toString: () => string } | string - toObject?: () => Record -} type SearchDataResponse = Partial> -const FUSE_OPTIONS = { - keys: [ - { name: "name", weight: 10 }, - { name: "originalName", weight: 8 }, - { name: "label", weight: 10 }, - ], - threshold: 0.15, - includeScore: true, - shouldSort: true, - minMatchCharLength: 2 -} - -type FuzzyCacheEntry = { - fuse: Fuse - rankedResultsByQuery: Map[]> -} - // Simple in-memory cache for search data let cachedData: UnifiedEntity[] | null = null let lastFetchTime = 0 @@ -130,53 +70,14 @@ async function getSearchData(): Promise { return cachedData } -function buildFilterCacheKey(options?: UnifiedSearchOptions): string | null { - if (!options) return null - - const entityTypes = options.specificEntityTypes?.length - ? [...options.specificEntityTypes].sort() - : options.specificEntityType - ? [options.specificEntityType] - : [] - - const itemTypes = options.itemTypes?.length ? [...options.itemTypes].sort() : [] - const circles = options.circles?.length ? [...options.circles].sort((left, right) => left - right) : [] - const parentClassId = options.parentClassId ?? null - - if (entityTypes.length === 0 && itemTypes.length === 0 && circles.length === 0 && parentClassId === null) { - return null - } - - return JSON.stringify({ - entityTypes, - itemTypes, - circles, - parentClassId, - }) -} - -export function filterEntitiesByOptions(items: UnifiedEntity[], options?: UnifiedSearchOptions): UnifiedEntity[] { - if (!options) return items - +function filterEntitiesByOptionsWithCache(items: UnifiedEntity[], options?: UnifiedSearchOptions): UnifiedEntity[] { const cacheKey = buildFilterCacheKey(options) if (cacheKey) { const cached = unifiedFilteredCache.get(cacheKey) if (cached) return cached } - const entityTypes = options.specificEntityTypes?.length - ? options.specificEntityTypes - : options.specificEntityType - ? [options.specificEntityType] - : null - - const filtered = items.filter((entity) => { - if (entityTypes && !entityTypes.includes(entity.type as EntityType)) return false - if (options.itemTypes?.length && entity.type === "Item" && !options.itemTypes.includes(entity.itemType ?? "")) return false - if (options.circles?.length && entity.type === "Magia" && !options.circles.includes(entity.circle ?? -1)) return false - if (options.parentClassId && entity.type === "Subclasse" && entity.metadata?.parentClassId !== options.parentClassId) return false - return true - }) + const filtered = filterEntities(items, options) if (cacheKey) { unifiedFilteredCache.set(cacheKey, filtered) @@ -189,22 +90,18 @@ function getFuzzyCacheEntry(items: T[]): FuzzyCacheE const cached = fuzzyCache.get(items as SearchableEntity[]) if (cached) return cached as unknown as FuzzyCacheEntry - const entry: FuzzyCacheEntry = { - fuse: new Fuse(items, FUSE_OPTIONS), - rankedResultsByQuery: new Map(), - } + const entry = createFuzzyCacheEntry(items) fuzzyCache.set(items as SearchableEntity[], entry as unknown as FuzzyCacheEntry) return entry } -function getRankedFuzzyResults(items: T[], query: string): FuseResult[] { - const cacheEntry = getFuzzyCacheEntry(items) - const cachedResults = cacheEntry.rankedResultsByQuery.get(query) - if (cachedResults) return cachedResults - - const fuseResults = cacheEntry.fuse.search(query) - cacheEntry.rankedResultsByQuery.set(query, fuseResults) - return fuseResults +function applyCachedFuzzySearch( + items: T[], + query: string, + limit?: number, + offset = 0 +): T[] { + return applyFuzzySearchWithCache(items, query, getFuzzyCacheEntry(items), limit, offset) } /** @@ -216,31 +113,7 @@ export function applyFuzzySearch( limit?: number, offset = 0 ): T[] { - if (!query.trim()) { - const sliced = items.slice(offset, limit ? offset + limit : undefined) - return sliced - } - - const rawResults = getRankedFuzzyResults(items, query) - const paginatedRawResults = limit - ? rawResults.slice(offset, offset + limit) - : rawResults.slice(offset) - - return paginatedRawResults.map((result) => { - // Handle both plain objects and Mongoose/class instances - const item = result.item as SearchResultItem - const baseItem = (typeof item.toObject === "function" ? item.toObject() : { ...result.item }) as Record & SearchResultItem - - // Ensure ID compatibility - const id = baseItem._id?.toString() || baseItem.id?.toString() - - return { - ...baseItem, - id: id, - _id: id, - score: result.score - } as unknown as T - }) + return applyCachedFuzzySearch(items, query, limit, offset) } /** @@ -255,8 +128,8 @@ export function peekUnifiedSearch( ): UnifiedEntity[] | null { if (!cachedData) return null - const filteredEntities = filterEntitiesByOptions(cachedData, options) - return applyFuzzySearch(filteredEntities, query, limit, offset) + const filteredEntities = filterEntitiesByOptionsWithCache(cachedData, options) + return applyCachedFuzzySearch(filteredEntities, query, limit, offset) } /** @@ -269,9 +142,9 @@ export async function performUnifiedSearch( options?: UnifiedSearchOptions ): Promise { const allEntities = await getSearchData() - const filteredEntities = filterEntitiesByOptions(allEntities, options) + const filteredEntities = filterEntitiesByOptionsWithCache(allEntities, options) - return applyFuzzySearch(filteredEntities, query, limit, offset) + return applyCachedFuzzySearch(filteredEntities, query, limit, offset) } /** @@ -290,4 +163,10 @@ export function invalidateSearchCache(): void { lastFetchTime = 0 fuzzyCache = new WeakMap>() unifiedFilteredCache.clear() + + if (typeof window !== "undefined") { + void import("@/core/utils/search-worker-client").then(({ invalidateSearchWorkerCache }) => { + invalidateSearchWorkerCache() + }) + } } diff --git a/src/core/utils/search-worker-client.ts b/src/core/utils/search-worker-client.ts new file mode 100644 index 00000000..127a90ec --- /dev/null +++ b/src/core/utils/search-worker-client.ts @@ -0,0 +1,194 @@ +import { performUnifiedSearch } from "@/core/utils/search-engine" +import type { UnifiedEntity, UnifiedSearchOptions } from "@/core/utils/search-core" + +type SearchWorkerRequest = + | { id: number; type: "search"; baseUrl: string; query: string; limit: number; offset: number; options?: UnifiedSearchOptions } + | { id: number; type: "warm"; baseUrl: string } + | { id: number; type: "invalidate" } + +type SearchWorkerResponse = + | { id: number; type: "partial-result"; results: UnifiedEntity[]; loadedProviders: number; totalProviders: number; done: false } + | { id: number; type: "result"; results: UnifiedEntity[] } + | { id: number; type: "warmed" } + | { id: number; type: "invalidated" } + | { id: number; type: "error"; error: string } + +export type ProgressiveSearchUpdate = { + results: UnifiedEntity[] + loadedProviders: number + totalProviders: number + done: boolean +} + +type PendingRequest = { + resolve: (results: UnifiedEntity[]) => void + reject: (error: Error) => void + onPartial?: (update: ProgressiveSearchUpdate) => void +} + +let worker: Worker | null = null +let requestId = 0 +const pendingRequests = new Map() + +function canUseWorker() { + return typeof window !== "undefined" && typeof Worker !== "undefined" +} + +function getWorkerBaseUrl() { + return window.location.origin +} + +function rejectPendingRequests(error: Error) { + pendingRequests.forEach(({ reject }) => reject(error)) + pendingRequests.clear() +} + +function getSearchWorker(): Worker | null { + if (!canUseWorker()) return null + if (worker) return worker + + try { + worker = new Worker(new URL("./search.worker.ts", import.meta.url), { type: "module" }) + worker.onmessage = (event: MessageEvent) => { + const message = event.data + const pending = pendingRequests.get(message.id) + if (!pending) return + + if (message.type === "error") { + pendingRequests.delete(message.id) + pending.reject(new Error(message.error)) + return + } + + if (message.type === "partial-result") { + pending.onPartial?.({ + results: message.results, + loadedProviders: message.loadedProviders, + totalProviders: message.totalProviders, + done: false, + }) + return + } + + pendingRequests.delete(message.id) + pending.resolve(message.type === "result" ? message.results : []) + } + worker.onerror = (event) => { + const error = new Error(event.message || "Search worker failed") + worker?.terminate() + worker = null + rejectPendingRequests(error) + } + } catch { + worker = null + } + + return worker +} + +function postWorkerRequest( + message: SearchWorkerRequest, + onPartial?: (update: ProgressiveSearchUpdate) => void +): Promise | null { + const searchWorker = getSearchWorker() + if (!searchWorker) return null + + return new Promise((resolve, reject) => { + pendingRequests.set(message.id, { resolve, reject, onPartial }) + console.log("[mention-search worker:post]", { + type: message.type, + query: message.type === "search" ? message.query : undefined, + baseUrl: "baseUrl" in message ? message.baseUrl : undefined, + }) + searchWorker.postMessage(message) + }) +} + +export async function performUnifiedSearchInWorker( + query: string, + limit = 20, + offset = 0, + options?: UnifiedSearchOptions +): Promise { + const id = ++requestId + const workerPromise = postWorkerRequest({ id, type: "search", baseUrl: canUseWorker() ? getWorkerBaseUrl() : "", query, limit, offset, options }) + + if (!workerPromise) { + return performUnifiedSearch(query, limit, offset, options) + } + + try { + return await workerPromise + } catch (err) { + console.error("Search worker failed, falling back to main-thread search:", { query, limit, offset, options, err }) + return performUnifiedSearch(query, limit, offset, options) + } +} + +export async function searchUnifiedInWorkerProgressively( + query: string, + limit = 20, + offset = 0, + options?: UnifiedSearchOptions, + onPartial?: (update: ProgressiveSearchUpdate) => void +): Promise { + const id = ++requestId + const workerPromise = postWorkerRequest( + { id, type: "search", baseUrl: canUseWorker() ? getWorkerBaseUrl() : "", query, limit, offset, options }, + onPartial, + ) + + if (!workerPromise) { + const results = await performUnifiedSearch(query, limit, offset, options) + onPartial?.({ + results, + loadedProviders: 1, + totalProviders: 1, + done: true, + }) + return results + } + + try { + const results = await workerPromise + onPartial?.({ + results, + loadedProviders: 1, + totalProviders: 1, + done: true, + }) + return results + } catch (err) { + console.error("Search worker failed, falling back to main-thread search:", { query, limit, offset, options, err }) + const results = await performUnifiedSearch(query, limit, offset, options) + onPartial?.({ + results, + loadedProviders: 1, + totalProviders: 1, + done: true, + }) + return results + } +} + +export function warmSearchWorkerCache(): void { + const id = ++requestId + const workerPromise = canUseWorker() + ? postWorkerRequest({ id, type: "warm", baseUrl: getWorkerBaseUrl() }) + : null + if (workerPromise) { + void workerPromise.catch((err) => { + console.error("Search worker warmup failed:", err) + }) + } +} + +export function invalidateSearchWorkerCache(): void { + const id = ++requestId + const workerPromise = postWorkerRequest({ id, type: "invalidate" }) + if (workerPromise) { + void workerPromise.catch((err) => { + console.error("Search worker invalidation failed:", err) + }) + } +} diff --git a/src/core/utils/search.worker.ts b/src/core/utils/search.worker.ts new file mode 100644 index 00000000..d3e7b6f9 --- /dev/null +++ b/src/core/utils/search.worker.ts @@ -0,0 +1,283 @@ +import { ENTITY_PROVIDERS } from "@/lib/config/entities" +import { + applyFuzzySearchWithCache, + buildFilterCacheKey, + createFuzzyCacheEntry, + filterEntitiesByOptions, + type FuzzyCacheEntry, + type UnifiedEntity, + type UnifiedSearchOptions, +} from "@/core/utils/search-core" + +type SearchDataResponse = Partial> + +type SearchWorkerRequest = + | { id: number; type: "search"; baseUrl: string; query: string; limit: number; offset: number; options?: UnifiedSearchOptions } + | { id: number; type: "warm"; baseUrl: string } + | { id: number; type: "invalidate" } + +type SearchWorkerResponse = + | { id: number; type: "partial-result"; results: UnifiedEntity[]; loadedProviders: number; totalProviders: number; done: false } + | { id: number; type: "result"; results: UnifiedEntity[] } + | { id: number; type: "warmed" } + | { id: number; type: "invalidated" } + | { id: number; type: "error"; error: string } + +type WorkerSearchScope = { + items: UnifiedEntity[] + fuzzyCache: FuzzyCacheEntry +} + +type EntityProvider = (typeof ENTITY_PROVIDERS)[number] +type ProviderStatus = "idle" | "loading" | "loaded" | "failed" + +let cachedData: UnifiedEntity[] | null = null +let lastFetchTime = 0 +const CACHE_TTL = 1000 * 60 * 5 +const scopedSearchCache = new Map() +const providerItems = new Map() +const providerStatus = new Map() +const providerPromises = new Map>() + +export function resolveWorkerEndpoint(endpoint: string, baseUrl: string): string { + return new URL(endpoint, baseUrl).toString() +} + +function postMessageToClient(message: SearchWorkerResponse) { + self.postMessage(message) +} + +function extractItems(data: SearchDataResponse | unknown[]): unknown[] { + if (Array.isArray(data)) return data + if (data.items) return data.items + if (data.spells) return data.spells + if (data.traits) return data.traits + if (data.rules) return data.rules + if (data.feats) return data.feats + if (data.classes) return data.classes + if (data.backgrounds) return data.backgrounds + if (data.races) return data.races + if (data.data) return data.data + return [] +} + +function searchItems( + items: UnifiedEntity[], + query: string, + limit: number, + offset: number, + options?: UnifiedSearchOptions +): UnifiedEntity[] { + const filteredItems = filterEntitiesByOptions(items, options) + const fuzzyCache = createFuzzyCacheEntry(filteredItems) + return applyFuzzySearchWithCache(filteredItems, query, fuzzyCache, limit, offset) +} + +function getProviderKey(provider: EntityProvider) { + return provider.name +} + +function isProviderCacheFresh() { + return lastFetchTime > 0 && Date.now() - lastFetchTime < CACHE_TTL +} + +function getCachedProviderItems() { + return Array.from(providerItems.values()).flat() +} + +function updateCachedDataFromProviders() { + cachedData = getCachedProviderItems() + scopedSearchCache.clear() + return cachedData +} + +function getCompletedProviderCount() { + return ENTITY_PROVIDERS.filter((provider) => { + const status = providerStatus.get(getProviderKey(provider)) + return status === "loaded" || status === "failed" + }).length +} + +function getFailedProviderCount() { + return ENTITY_PROVIDERS.filter((provider) => providerStatus.get(getProviderKey(provider)) === "failed").length +} + +function clearStaleProviderCache() { + if (!lastFetchTime || Date.now() - lastFetchTime < CACHE_TTL) return + invalidateWorkerCache() +} + +async function fetchProviderItems(provider: EntityProvider, baseUrl: string): Promise { + const endpointUrl = resolveWorkerEndpoint(provider.endpoint(), baseUrl) + console.log("[mention-search worker:fetch]", { provider: provider.name, endpointUrl }) + const res = await fetch(endpointUrl) + if (!res.ok) { + throw new Error(`${res.status} ${res.statusText}`) + } + + const data = await res.json() as SearchDataResponse | unknown[] + return extractItems(data).flatMap((item) => provider.map(item)) +} + +function ensureProviderLoad(provider: EntityProvider, baseUrl: string): Promise { + clearStaleProviderCache() + + const key = getProviderKey(provider) + const status = providerStatus.get(key) + const cachedItems = providerItems.get(key) + if (status === "loaded" && cachedItems && isProviderCacheFresh()) { + return Promise.resolve(cachedItems) + } + + const existingPromise = providerPromises.get(key) + if (existingPromise) { + return existingPromise + } + + providerStatus.set(key, "loading") + const promise = fetchProviderItems(provider, baseUrl) + .then((items) => { + providerItems.set(key, items) + providerStatus.set(key, "loaded") + providerPromises.delete(key) + lastFetchTime = Date.now() + updateCachedDataFromProviders() + return items + }) + .catch((err) => { + providerStatus.set(key, "failed") + providerPromises.delete(key) + throw err + }) + + providerPromises.set(key, promise) + return promise +} + +function startAllProviderLoads(baseUrl: string): Promise[] { + return ENTITY_PROVIDERS.map((provider) => ensureProviderLoad(provider, baseUrl)) +} + +function getCurrentSearchResults(message: Extract) { + return searchItems(getCachedProviderItems(), message.query, message.limit, message.offset, message.options) +} + +function postPartialSearchResult(message: Extract) { + const results = getCurrentSearchResults(message) + const loadedProviders = getCompletedProviderCount() + const totalProviders = ENTITY_PROVIDERS.length + console.log("[mention-search worker:partial-result]", { + query: message.query, + loadedProviders, + totalProviders, + total: getCachedProviderItems().length, + results: results.length, + }) + postMessageToClient({ + id: message.id, + type: "partial-result", + results, + loadedProviders, + totalProviders, + done: false, + }) +} + +async function searchProgressively(message: Extract): Promise { + clearStaleProviderCache() + + if (cachedData && isProviderCacheFresh() && providerItems.size === ENTITY_PROVIDERS.length) { + const scope = getSearchScope(cachedData, message.options) + const results = applyFuzzySearchWithCache(scope.items, message.query, scope.fuzzyCache, message.limit, message.offset) + console.log("[mention-search worker:result]", { query: message.query, scopedItems: scope.items.length, results: results.length }) + postMessageToClient({ id: message.id, type: "result", results }) + return + } + + const totalProviders = ENTITY_PROVIDERS.length + if (providerItems.size > 0) { + postPartialSearchResult(message) + } + + const watchedProviders = ENTITY_PROVIDERS.filter((provider) => providerStatus.get(getProviderKey(provider)) !== "loaded") + await Promise.all(watchedProviders.map(async (provider) => { + try { + await ensureProviderLoad(provider, message.baseUrl) + postPartialSearchResult(message) + return true + } catch (err) { + console.error(`Worker fetch failed for ${provider.name}:`, err) + if (getFailedProviderCount() < totalProviders && getCachedProviderItems().length > 0) { + postPartialSearchResult(message) + } + return false + } + })) + + const failedProviders = getFailedProviderCount() + if (failedProviders === totalProviders) { + throw new Error("All worker search providers failed") + } + + cachedData = updateCachedDataFromProviders() + console.log("[mention-search worker:data]", { total: cachedData.length, failedProviders }) + + const scope = getSearchScope(cachedData, message.options) + const results = applyFuzzySearchWithCache(scope.items, message.query, scope.fuzzyCache, message.limit, message.offset) + console.log("[mention-search worker:result]", { query: message.query, scopedItems: scope.items.length, results: results.length }) + postMessageToClient({ id: message.id, type: "result", results }) +} + +function getSearchScope(items: UnifiedEntity[], options?: UnifiedSearchOptions): WorkerSearchScope { + const key = buildFilterCacheKey(options) ?? "__all__" + const cachedScope = scopedSearchCache.get(key) + if (cachedScope) return cachedScope + + const filteredItems = filterEntitiesByOptions(items, options) + const scope = { + items: filteredItems, + fuzzyCache: createFuzzyCacheEntry(filteredItems), + } + + scopedSearchCache.set(key, scope) + return scope +} + +function invalidateWorkerCache() { + cachedData = null + lastFetchTime = 0 + scopedSearchCache.clear() + providerItems.clear() + providerStatus.clear() + providerPromises.clear() +} + +self.onmessage = (event: MessageEvent) => { + const message = event.data + + void (async () => { + try { + if (message.type === "invalidate") { + invalidateWorkerCache() + postMessageToClient({ id: message.id, type: "invalidated" }) + return + } + + if (message.type === "warm") { + startAllProviderLoads(message.baseUrl).forEach((promise) => { + void promise.catch(() => undefined) + }) + postMessageToClient({ id: message.id, type: "warmed" }) + return + } + + await searchProgressively(message) + } catch (err) { + postMessageToClient({ + id: message.id, + type: "error", + error: err instanceof Error ? err.message : "Unknown worker search error", + }) + } + })() +} diff --git a/src/features/rules/components/entity-preview-tooltip.tsx b/src/features/rules/components/entity-preview-tooltip.tsx index 8bbea9c0..aac9d8ce 100644 --- a/src/features/rules/components/entity-preview-tooltip.tsx +++ b/src/features/rules/components/entity-preview-tooltip.tsx @@ -112,6 +112,64 @@ type SubclassPreviewData = { subclass: Subclass } +const normalizeMonsterPreviewData = (json: unknown): Monster => { + const data = (json && typeof json === "object" ? json : {}) as Partial & Record + + return { + _id: String(data._id || data.id || ""), + id: String(data.id || data._id || ""), + name: String(data.name || "Monstro"), + originalName: data.originalName, + source: String(data.source || ""), + description: String(data.description || ""), + image: typeof data.image === "string" ? data.image : undefined, + status: data.status === "inactive" ? "inactive" : "active", + type: data.type || "beast", + size: data.size || "M", + alignment: data.alignment || "unaligned", + armorClass: data.armorClass ?? "—", + initiative: data.initiative, + hitPointsFormula: String(data.hitPointsFormula || "0"), + speed: data.speed, + flySpeed: data.flySpeed, + swimSpeed: data.swimSpeed, + climbSpeed: data.climbSpeed, + attributes: { + strength: data.attributes?.strength ?? 10, + dexterity: data.attributes?.dexterity ?? 10, + constitution: data.attributes?.constitution ?? 10, + intelligence: data.attributes?.intelligence ?? 10, + wisdom: data.attributes?.wisdom ?? 10, + charisma: data.attributes?.charisma ?? 10, + }, + savingThrows: data.savingThrows || {}, + skills: data.skills || {}, + senses: data.senses || {}, + sensesAndLanguages: Array.isArray(data.sensesAndLanguages) ? data.sensesAndLanguages : [], + challengeRating: String(data.challengeRating || "0"), + experience: data.experience, + experienceOverride: data.experienceOverride, + proficiencyBonusOverride: data.proficiencyBonusOverride, + languages: String(data.languages || ""), + damageVulnerabilities: Array.isArray(data.damageVulnerabilities) ? data.damageVulnerabilities : [], + damageResistances: Array.isArray(data.damageResistances) ? data.damageResistances : [], + damageImmunities: Array.isArray(data.damageImmunities) ? data.damageImmunities : [], + conditionImmunities: Array.isArray(data.conditionImmunities) ? data.conditionImmunities : [], + conditionImmunityNotes: data.conditionImmunityNotes, + traits: Array.isArray(data.traits) ? data.traits : [], + actions: Array.isArray(data.actions) ? data.actions : [], + bonusActions: Array.isArray(data.bonusActions) ? data.bonusActions : [], + reactions: Array.isArray(data.reactions) ? data.reactions : [], + legendaryActions: Array.isArray(data.legendaryActions) ? data.legendaryActions : [], + legendaryActionUses: data.legendaryActionUses, + lairActions: Array.isArray(data.lairActions) ? data.lairActions : [], + lairActionInitiative: data.lairActionInitiative, + regionalEffects: Array.isArray(data.regionalEffects) ? data.regionalEffects : [], + createdAt: String(data.createdAt || ""), + updatedAt: String(data.updatedAt || ""), + } +} + export const TraitPreview = ({ trait, showStatus = true, hideStatusChip = false, hideActionIcons = false }: TraitPreviewProps & { hideStatusChip?: boolean; hideActionIcons?: boolean }) => { const { addWindow } = useWindows() return ( @@ -270,6 +328,10 @@ const getEntityPreviewEndpoint = (entityId: string, entityType: string) => { } const normalizeEntityPreviewData = (entityId: string, entityType: string, json: unknown) => { + if (entityType === "Monstro") { + return normalizeMonsterPreviewData(json) + } + if (entityType !== "Subclasse") { return json } @@ -299,7 +361,6 @@ const useEntityPreviewData = ({ entityId, entityType, enabled }: { entityId: str React.useEffect(() => { if (!enabled) { - setLoading(false) return } diff --git a/src/features/rules/utils/suggestion.ts b/src/features/rules/utils/suggestion.ts index 6a6a64e7..09b84cda 100644 --- a/src/features/rules/utils/suggestion.ts +++ b/src/features/rules/utils/suggestion.ts @@ -1,10 +1,16 @@ import { ReactRenderer } from '@tiptap/react' import type { Editor } from '@tiptap/core' -import tippy from 'tippy.js' +import type { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion' +import tippy, { type Instance } from 'tippy.js' import MentionList, { MentionListProps, MentionListRef } from '../components/mention-list' -import { performUnifiedSearch, peekUnifiedSearch, type UnifiedEntity, type UnifiedSearchOptions } from '@/core/utils/search-engine' +import { searchUnifiedInWorkerProgressively } from '@/core/utils/search-worker-client' +import type { UnifiedEntity, UnifiedSearchOptions } from '@/core/utils/search-core' import type { EntityType } from '@/lib/config/colors' +type MentionListItem = MentionListProps["items"][number] +type MentionSuggestionProps = SuggestionProps +const MENTION_SEARCH_DEBOUNCE_MS = 300 + /** * T039: Updated to support both Regra and Habilidade entity types in mentions. * Fetches from central search engine. @@ -27,6 +33,7 @@ export const getSuggestionConfig = (options?: { let wasSelection = false let debounceTimer: NodeJS.Timeout | null = null let latestQueryId = 0 + const cachedItemsByQuery = new Map() const searchOptions: UnifiedSearchOptions = { specificEntityType: options?.specificEntityMention, @@ -43,14 +50,15 @@ export const getSuggestionConfig = (options?: { ) .map((item) => ({ ...item, + id: item.id || item._id || "", + label: item.label || item.name, entityType: item.type, })) - const getCachedItems = (query: string) => - normalizeResults(peekUnifiedSearch(query, 10, 0, searchOptions) ?? []) + const getCachedItems = (query: string) => cachedItemsByQuery.get(query) ?? [] - const wrapCommand = (props: any) => { - return (item: any) => { + const wrapCommand = (props: MentionSuggestionProps) => { + return (item: MentionListItem) => { wasSelection = true props.command(item) if (!options?.blurOnMentionSelect) return @@ -63,7 +71,7 @@ export const getSuggestionConfig = (options?: { } return { - items: async ({ query }: { query: string }) => { + items: ({ query }: { query: string }) => { currentQuery = query loading = true const cachedItems = getCachedItems(query) @@ -78,33 +86,71 @@ export const getSuggestionConfig = (options?: { const queryId = ++latestQueryId - return new Promise((resolve) => { - if (debounceTimer) clearTimeout(debounceTimer) + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + + debounceTimer = setTimeout(() => { + debounceTimer = null - debounceTimer = setTimeout(async () => { - if (queryId !== latestQueryId) return resolve(cachedItems) + void (async () => { + if (queryId !== latestQueryId) return try { - const results = await performUnifiedSearch(query, 10, 0, searchOptions) - if (queryId !== latestQueryId) return resolve(cachedItems) + const results = await searchUnifiedInWorkerProgressively(query, 10, 0, searchOptions, (update) => { + if (queryId !== latestQueryId) return + + const filteredResults = normalizeResults(update.results) + if (update.done) { + cachedItemsByQuery.set(query, filteredResults) + } + + loading = !update.done + if (component) { + component.updateProps({ + items: filteredResults, + loading: !update.done, + query, + }) + } + }) + if (queryId !== latestQueryId) return const filteredResults = normalizeResults(results) + cachedItemsByQuery.set(query, filteredResults) loading = false - resolve(filteredResults) + if (component) { + component.updateProps({ + items: filteredResults, + loading: false, + query, + }) + } } catch (e) { console.error("Mention search system failed:", e) + if (queryId !== latestQueryId) return + loading = false - resolve([]) + if (component) { + component.updateProps({ + items: [], + loading: false, + query, + }) + } } - }, 150) - }) + })() + }, MENTION_SEARCH_DEBOUNCE_MS) + + return cachedItems }, render: () => { - let popup: any + let popup: Instance | null = null return { - onStart: (props: any) => { + onStart: (props: MentionSuggestionProps) => { currentEditor = props.editor wasSelection = false options?.onStart?.({ editor: currentEditor }) @@ -115,7 +161,7 @@ export const getSuggestionConfig = (options?: { ...props, items: cachedItems.length > 0 ? cachedItems : props.items, command: wrapCommand(props), - loading: cachedItems.length === 0, + loading, query: currentQuery, }, editor: props.editor, @@ -125,8 +171,8 @@ export const getSuggestionConfig = (options?: { return } - popup = tippy("body" as any, { - getReferenceClientRect: props.clientRect, + popup = tippy(document.body, { + getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(), appendTo: () => document.body, content: component.element, showOnCreate: true, @@ -145,7 +191,7 @@ export const getSuggestionConfig = (options?: { }) }, - onUpdate: (props: any) => { + onUpdate: (props: MentionSuggestionProps) => { currentEditor = props.editor if (component) { component.updateProps({ ...props, command: wrapCommand(props), loading, query: currentQuery }) @@ -155,22 +201,25 @@ export const getSuggestionConfig = (options?: { return } - popup[0].setProps({ - getReferenceClientRect: props.clientRect, + popup.setProps({ + getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(), }) }, - onKeyDown: (props: any) => { + onKeyDown: (props: SuggestionKeyDownProps) => { if (props.event.key === "Escape") { - popup[0].hide() + popup?.hide() return true } if (props.event.key === "ArrowRight") { - const { selection } = props.editor.state + const editor = currentEditor + if (!editor) return false + + const { selection } = editor.state if (selection.empty && selection.from >= props.range.to) { // Step out of the badge by inserting a zero-width space - props.editor.commands.insertContent("\u200B") + editor.commands.insertContent("\u200B") return true } } @@ -180,8 +229,12 @@ export const getSuggestionConfig = (options?: { onExit: () => { options?.onExit?.({ editor: currentEditor, wasSelection }) - if (popup && popup[0]) { - popup[0].destroy() + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + if (popup) { + popup.destroy() } if (component) { component.destroy() diff --git a/src/lib/config/entities.ts b/src/lib/config/entities.ts index f3f5d426..bd9a799c 100644 --- a/src/lib/config/entities.ts +++ b/src/lib/config/entities.ts @@ -1,5 +1,5 @@ import { entityColors } from "@/lib/config/colors" -import { UnifiedEntity } from "@/core/utils/search-engine" +import type { UnifiedEntity } from "@/core/utils/search-core" /** * @fileoverview Unified entity configuration and data providers. diff --git a/src/lib/config/version.ts b/src/lib/config/version.ts index 535afdbf..1c483fc6 100644 --- a/src/lib/config/version.ts +++ b/src/lib/config/version.ts @@ -1 +1 @@ -export const APP_VERSION = "v3.3.3" +export const APP_VERSION = "v3.3.4" diff --git a/tests/frontend/core/search-worker-client.test.ts b/tests/frontend/core/search-worker-client.test.ts new file mode 100644 index 00000000..00925e99 --- /dev/null +++ b/tests/frontend/core/search-worker-client.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +type WorkerMessage = { + id: number + type: string + baseUrl?: string + query?: string + limit?: number + offset?: number +} + +class MockWorker { + static instances: MockWorker[] = [] + + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + messages: WorkerMessage[] = [] + terminate = vi.fn() + + constructor() { + MockWorker.instances.push(this) + } + + postMessage(message: WorkerMessage) { + this.messages.push(message) + } + + respond(message: Record) { + this.onmessage?.({ data: message } as MessageEvent) + } +} + +const fallbackSearch = vi.fn() + +async function importClient() { + vi.resetModules() + vi.doMock("@/core/utils/search-engine", () => ({ + performUnifiedSearch: fallbackSearch, + })) + + return import("@/core/utils/search-worker-client") +} + +describe("search-worker-client", () => { + beforeEach(() => { + MockWorker.instances = [] + fallbackSearch.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock("@/core/utils/search-engine") + }) + + it("resolves search results returned by the worker", async () => { + vi.stubGlobal("Worker", MockWorker) + const { performUnifiedSearchInWorker } = await importClient() + + const promise = performUnifiedSearchInWorker("fire", 10, 0) + const worker = MockWorker.instances[0] + + expect(worker.messages[0]).toEqual(expect.objectContaining({ + type: "search", + baseUrl: window.location.origin, + query: "fire", + limit: 10, + offset: 0, + })) + + worker.respond({ + id: worker.messages[0].id, + type: "result", + results: [{ id: "spell-1", name: "Fire Bolt", type: "Magia", status: "active" }], + }) + + await expect(promise).resolves.toEqual([ + { id: "spell-1", name: "Fire Bolt", type: "Magia", status: "active" }, + ]) + expect(fallbackSearch).not.toHaveBeenCalled() + }) + + it("passes partial worker results to the progressive search callback", async () => { + vi.stubGlobal("Worker", MockWorker) + const { searchUnifiedInWorkerProgressively } = await importClient() + const onPartial = vi.fn() + + const promise = searchUnifiedInWorkerProgressively("fire", 10, 0, undefined, onPartial) + const worker = MockWorker.instances[0] + + worker.respond({ + id: worker.messages[0].id, + type: "partial-result", + results: [{ id: "rule-1", name: "Fire Rules", type: "Regra", status: "active" }], + loadedProviders: 1, + totalProviders: 2, + done: false, + }) + + expect(onPartial).toHaveBeenCalledWith({ + results: [{ id: "rule-1", name: "Fire Rules", type: "Regra", status: "active" }], + loadedProviders: 1, + totalProviders: 2, + done: false, + }) + + worker.respond({ + id: worker.messages[0].id, + type: "result", + results: [{ id: "spell-1", name: "Fire Bolt", type: "Magia", status: "active" }], + }) + + await expect(promise).resolves.toEqual([ + { id: "spell-1", name: "Fire Bolt", type: "Magia", status: "active" }, + ]) + expect(onPartial).toHaveBeenLastCalledWith({ + results: [{ id: "spell-1", name: "Fire Bolt", type: "Magia", status: "active" }], + loadedProviders: 1, + totalProviders: 1, + done: true, + }) + }) + + it("matches out-of-order worker responses to the correct request", async () => { + vi.stubGlobal("Worker", MockWorker) + const { performUnifiedSearchInWorker } = await importClient() + + const first = performUnifiedSearchInWorker("fire", 10, 0) + const second = performUnifiedSearchInWorker("cold", 10, 0) + const worker = MockWorker.instances[0] + const [firstMessage, secondMessage] = worker.messages + + worker.respond({ + id: secondMessage.id, + type: "result", + results: [{ id: "cold", name: "Cold Rules", type: "Regra", status: "active" }], + }) + worker.respond({ + id: firstMessage.id, + type: "result", + results: [{ id: "fire", name: "Fire Rules", type: "Regra", status: "active" }], + }) + + await expect(first).resolves.toEqual([ + { id: "fire", name: "Fire Rules", type: "Regra", status: "active" }, + ]) + await expect(second).resolves.toEqual([ + { id: "cold", name: "Cold Rules", type: "Regra", status: "active" }, + ]) + }) + + it("falls back to the main-thread search when Worker is unavailable", async () => { + vi.stubGlobal("Worker", undefined) + fallbackSearch.mockResolvedValue([{ id: "fallback", name: "Fallback", type: "Regra", status: "active" }]) + const { performUnifiedSearchInWorker } = await importClient() + + await expect(performUnifiedSearchInWorker("fallback", 5, 1)).resolves.toEqual([ + { id: "fallback", name: "Fallback", type: "Regra", status: "active" }, + ]) + expect(fallbackSearch).toHaveBeenCalledWith("fallback", 5, 1, undefined) + }) + + it("sends the page origin when warming the worker cache", async () => { + vi.stubGlobal("Worker", MockWorker) + const { warmSearchWorkerCache } = await importClient() + + warmSearchWorkerCache() + + expect(MockWorker.instances[0].messages[0]).toEqual(expect.objectContaining({ + type: "warm", + baseUrl: window.location.origin, + })) + }) + + it("falls back to the main-thread search when the worker reports an error", async () => { + vi.stubGlobal("Worker", MockWorker) + fallbackSearch.mockResolvedValue([{ id: "fallback-error", name: "Fallback Error", type: "Regra", status: "active" }]) + const { performUnifiedSearchInWorker } = await importClient() + + const promise = performUnifiedSearchInWorker("fire", 10, 0) + const worker = MockWorker.instances[0] + + worker.respond({ + id: worker.messages[0].id, + type: "error", + error: "All worker search providers failed", + }) + + await expect(promise).resolves.toEqual([ + { id: "fallback-error", name: "Fallback Error", type: "Regra", status: "active" }, + ]) + expect(fallbackSearch).toHaveBeenCalledWith("fire", 10, 0, undefined) + }) +}) diff --git a/tests/frontend/core/search-worker.test.ts b/tests/frontend/core/search-worker.test.ts new file mode 100644 index 00000000..231694d6 --- /dev/null +++ b/tests/frontend/core/search-worker.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mockProviders = vi.hoisted(() => ({ + providers: [ + { + name: "Regra", + endpoint: () => "/api/rules", + map: (item: { id: string; name: string }) => ({ ...item, _id: item.id, label: item.name, type: "Regra", status: "active" }), + }, + { + name: "Magia", + endpoint: () => "/api/spells/search", + map: (item: { id: string; name: string }) => ({ ...item, _id: item.id, label: item.name, type: "Magia", status: "active" }), + }, + ], +})) + +vi.mock("@/lib/config/entities", () => ({ + ENTITY_PROVIDERS: mockProviders.providers, +})) + +describe("search worker", () => { + beforeEach(() => { + vi.resetModules() + vi.restoreAllMocks() + }) + + it("resolves relative API endpoints against the page origin", async () => { + const { resolveWorkerEndpoint } = await import("@/core/utils/search.worker") + + expect(resolveWorkerEndpoint("/api/rules", "https://example.com")).toBe("https://example.com/api/rules") + expect(resolveWorkerEndpoint("https://api.example.com/rules", "https://example.com")).toBe("https://api.example.com/rules") + }) + + it("responds with an error instead of caching empty data when all providers fail", async () => { + const responses: unknown[] = [] + vi.stubGlobal("fetch", vi.fn(async () => { + throw new TypeError("invalid url") + })) + vi.spyOn(self, "postMessage").mockImplementation((message) => { + responses.push(message) + }) + + await import("@/core/utils/search.worker") + self.onmessage?.({ + data: { + id: 1, + type: "search", + baseUrl: "https://example.com", + query: "fire", + limit: 10, + offset: 0, + }, + } as MessageEvent) + + await vi.waitFor(() => { + expect(responses).toHaveLength(1) + }) + expect(responses[0]).toEqual({ + id: 1, + type: "error", + error: "All worker search providers failed", + }) + }) + + it("emits partial results as each provider finishes before the final result", async () => { + const responses: unknown[] = [] + let resolveRules: (response: { ok: boolean; json: () => Promise }) => void = () => undefined + let resolveSpells: (response: { ok: boolean; json: () => Promise }) => void = () => undefined + + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith("/api/rules")) { + return new Promise((resolve) => { + resolveRules = resolve + }) + } + + if (url.endsWith("/api/spells/search")) { + return new Promise((resolve) => { + resolveSpells = resolve + }) + } + + throw new Error(`Unexpected URL: ${url}`) + })) + vi.spyOn(self, "postMessage").mockImplementation((message) => { + responses.push(message) + }) + + await import("@/core/utils/search.worker") + self.onmessage?.({ + data: { + id: 1, + type: "search", + baseUrl: "https://example.com", + query: "fire", + limit: 10, + offset: 0, + }, + } as MessageEvent) + + resolveRules({ + ok: true, + json: async () => ({ items: [{ id: "rule-1", name: "Fire Rules" }] }), + }) + + await vi.waitFor(() => { + expect(responses).toHaveLength(1) + }) + expect(responses[0]).toEqual(expect.objectContaining({ + id: 1, + type: "partial-result", + loadedProviders: 1, + totalProviders: 2, + done: false, + })) + expect((responses[0] as { results: Array<{ id: string }> }).results.map((item) => item.id)).toEqual(["rule-1"]) + + resolveSpells({ + ok: true, + json: async () => ({ items: [{ id: "spell-1", name: "Fire Bolt" }] }), + }) + + await vi.waitFor(() => { + expect(responses).toHaveLength(3) + }) + expect(responses[1]).toEqual(expect.objectContaining({ + id: 1, + type: "partial-result", + loadedProviders: 2, + totalProviders: 2, + done: false, + })) + expect(responses[2]).toEqual(expect.objectContaining({ + id: 1, + type: "result", + })) + expect((responses[2] as { results: Array<{ id: string }> }).results.map((item) => item.id)).toEqual(["rule-1", "spell-1"]) + }) + + it("keeps successful provider results when another provider fails", async () => { + const responses: unknown[] = [] + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith("/api/rules")) { + return { + ok: true, + json: async () => ({ items: [{ id: "rule-1", name: "Fire Rules" }] }), + } + } + + throw new Error(`Provider failed: ${url}`) + })) + vi.spyOn(self, "postMessage").mockImplementation((message) => { + responses.push(message) + }) + + await import("@/core/utils/search.worker") + self.onmessage?.({ + data: { + id: 1, + type: "search", + baseUrl: "https://example.com", + query: "fire", + limit: 10, + offset: 0, + }, + } as MessageEvent) + + await vi.waitFor(() => { + expect(responses.some((message) => (message as { type: string }).type === "result")).toBe(true) + }) + + const finalResponse = responses.find((message) => (message as { type: string }).type === "result") as { results: Array<{ id: string }> } + expect(finalResponse.results.map((item) => item.id)).toEqual(["rule-1"]) + expect(responses.some((message) => (message as { type: string }).type === "error")).toBe(false) + }) + + it("lets a search use provider data that finished during a background warmup", async () => { + const responses: unknown[] = [] + let resolveRules: (response: { ok: boolean; json: () => Promise }) => void = () => undefined + let resolveSpells: (response: { ok: boolean; json: () => Promise }) => void = () => undefined + + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith("/api/rules")) { + return new Promise((resolve) => { + resolveRules = resolve + }) + } + + if (url.endsWith("/api/spells/search")) { + return new Promise((resolve) => { + resolveSpells = resolve + }) + } + + throw new Error(`Unexpected URL: ${url}`) + })) + vi.spyOn(self, "postMessage").mockImplementation((message) => { + responses.push(message) + }) + + await import("@/core/utils/search.worker") + self.onmessage?.({ + data: { + id: 1, + type: "warm", + baseUrl: "https://example.com", + }, + } as MessageEvent) + + await vi.waitFor(() => { + expect(responses).toEqual([{ id: 1, type: "warmed" }]) + }) + + resolveRules({ + ok: true, + json: async () => ({ items: [{ id: "rule-1", name: "Fire Rules" }] }), + }) + + await vi.waitFor(() => { + expect(fetch).toHaveBeenCalledTimes(2) + }) + + self.onmessage?.({ + data: { + id: 2, + type: "search", + baseUrl: "https://example.com", + query: "fire", + limit: 10, + offset: 0, + }, + } as MessageEvent) + + await vi.waitFor(() => { + expect(responses.some((message) => (message as { id: number; type: string }).id === 2 && (message as { type: string }).type === "partial-result")).toBe(true) + }) + + const partial = responses.find((message) => (message as { id: number; type: string }).id === 2 && (message as { type: string }).type === "partial-result") as { results: Array<{ id: string }> } + expect(partial.results.map((item) => item.id)).toEqual(["rule-1"]) + expect(fetch).toHaveBeenCalledTimes(2) + + resolveSpells({ + ok: true, + json: async () => ({ items: [{ id: "spell-1", name: "Fire Bolt" }] }), + }) + + await vi.waitFor(() => { + expect(responses.some((message) => (message as { id: number; type: string }).id === 2 && (message as { type: string }).type === "result")).toBe(true) + }) + expect(fetch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/tests/frontend/core/use-warm-search-cache.test.tsx b/tests/frontend/core/use-warm-search-cache.test.tsx new file mode 100644 index 00000000..3a25ecfb --- /dev/null +++ b/tests/frontend/core/use-warm-search-cache.test.tsx @@ -0,0 +1,47 @@ +import { render } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { useWarmSearchCache } from "@/core/hooks/useWarmSearchCache" + +const mocks = vi.hoisted(() => ({ + warmSearchCache: vi.fn(), + warmSearchWorkerCache: vi.fn(), +})) + +vi.mock("@/core/utils/search-engine", () => ({ + warmSearchCache: mocks.warmSearchCache, +})) + +vi.mock("@/core/utils/search-worker-client", () => ({ + warmSearchWorkerCache: mocks.warmSearchWorkerCache, +})) + +function TestComponent() { + useWarmSearchCache() + return null +} + +describe("useWarmSearchCache", () => { + beforeEach(() => { + vi.useFakeTimers() + mocks.warmSearchCache.mockReset() + mocks.warmSearchWorkerCache.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("delays search cache warmup to avoid blocking the initial render", () => { + render() + + vi.advanceTimersByTime(1499) + + expect(mocks.warmSearchCache).not.toHaveBeenCalled() + expect(mocks.warmSearchWorkerCache).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + + expect(mocks.warmSearchCache).toHaveBeenCalledTimes(1) + expect(mocks.warmSearchWorkerCache).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/frontend/rules/entity-preview-tooltip.test.tsx b/tests/frontend/rules/entity-preview-tooltip.test.tsx index 017cae31..dc5e8be8 100644 --- a/tests/frontend/rules/entity-preview-tooltip.test.tsx +++ b/tests/frontend/rules/entity-preview-tooltip.test.tsx @@ -54,7 +54,9 @@ vi.mock('@/components/ui/glass-popover', async () => { }) vi.mock('@/features/monsters/components/monster-preview', () => ({ - MonsterPreview: ({ monster }: { monster: { name: string } }) =>
{monster.name}
, + MonsterPreview: ({ monster }: { monster: { name: string; attributes?: { wisdom?: number } } }) => ( +
{monster.name}:{monster.attributes?.wisdom}
+ ), })) vi.mock('@/features/spells/components/spell-preview', () => ({ @@ -126,7 +128,30 @@ describe('EntityPreviewTooltip', () => { }) expect(fetch).toHaveBeenCalledWith('/api/monsters/monster-1') - expect(screen.getByTestId('monster-preview')).toHaveTextContent('Dragão Vermelho') + expect(screen.getByTestId('monster-preview')).toHaveTextContent('Dragão Vermelho:10') + }) + + it('normalizes incomplete monster preview payloads before rendering MonsterPreview', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ _id: 'monster-1', name: 'Dragão Parcial', status: 'active' }), + })) + + render( + + + , + ) + + fireEvent.mouseEnter(screen.getByRole('button', { name: 'Dragão Parcial' })) + + await act(async () => { + vi.advanceTimersByTime(300) + await Promise.resolve() + await Promise.resolve() + }) + + expect(screen.getByTestId('monster-preview')).toHaveTextContent('Dragão Parcial:10') }) it('renders the AI generation action for admin spell previews', async () => { @@ -205,7 +230,7 @@ describe('EntityPreviewTooltip', () => { fireEvent.click(screen.getByText('Gerar com IA')) expect(fetch).toHaveBeenCalledWith('/api/monsters/monster-1') - expect(screen.getByTestId('monster-preview')).toHaveTextContent('Dragão Vermelho') + expect(screen.getByTestId('monster-preview')).toHaveTextContent('Dragão Vermelho:10') expect(screen.getByTestId('generation-modal')).toHaveTextContent('Dragão Vermelho') }) }) diff --git a/tests/frontend/rules/suggestion-worker-search.test.ts b/tests/frontend/rules/suggestion-worker-search.test.ts new file mode 100644 index 00000000..f1e67aa5 --- /dev/null +++ b/tests/frontend/rules/suggestion-worker-search.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + searchUnifiedInWorkerProgressively: vi.fn(), + peekUnifiedSearch: vi.fn(), + reactRendererInstances: [] as Array<{ + props: Record + updateProps: ReturnType + destroy: ReturnType + element: HTMLDivElement + }>, +})) + +vi.mock("@/core/utils/search-worker-client", () => ({ + searchUnifiedInWorkerProgressively: mocks.searchUnifiedInWorkerProgressively, +})) + +vi.mock("@/core/utils/search-engine", () => ({ + peekUnifiedSearch: mocks.peekUnifiedSearch, +})) + +vi.mock("@tiptap/react", () => ({ + ReactRenderer: class MockReactRenderer { + props: Record + updateProps = vi.fn((nextProps: Record) => { + this.props = { ...this.props, ...nextProps } + }) + destroy = vi.fn() + element = document.createElement("div") + ref = null + + constructor(_component: unknown, options: { props: Record }) { + this.props = options.props + mocks.reactRendererInstances.push(this) + } + }, +})) + +vi.mock("tippy.js", () => ({ + default: vi.fn(() => ({ + destroy: vi.fn(), + hide: vi.fn(), + setProps: vi.fn(), + })), +})) + +describe("getSuggestionConfig worker search", () => { + beforeEach(() => { + vi.useFakeTimers() + vi.resetModules() + mocks.searchUnifiedInWorkerProgressively.mockReset() + mocks.peekUnifiedSearch.mockReset() + mocks.reactRendererInstances.length = 0 + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("returns immediately and updates the rendered mention list with worker results", async () => { + const editor = { + isDestroyed: false, + commands: { blur: vi.fn(), insertContent: vi.fn() }, + state: { selection: { empty: true, from: 1 } }, + } + let resolveWorkerSearch: (items: unknown[]) => void = () => undefined + mocks.searchUnifiedInWorkerProgressively.mockReturnValue(new Promise((resolve) => { + resolveWorkerSearch = resolve + })) + + const workerResults = [ + { id: "spell-1", _id: "spell-1", name: "Raio de Fogo", label: "Raio de Fogo", type: "Magia", status: "active" }, + { id: "excluded", _id: "excluded", name: "Excluir", label: "Excluir", type: "Regra", status: "active" }, + ] + + const { getSuggestionConfig } = await import("@/features/rules/utils/suggestion") + const config = getSuggestionConfig({ + excludeId: "excluded", + specificEntityMentions: ["Magia", "Regra"], + }) + const renderer = config.render() + + const initialItems = await config.items({ query: "raio" }) + renderer.onStart?.({ + editor, + items: initialItems, + query: "raio", + text: "@raio", + range: { from: 1, to: 6 }, + command: vi.fn(), + decorationNode: null, + clientRect: () => new DOMRect(), + }) + + expect(initialItems).toEqual([]) + expect(mocks.reactRendererInstances[0].props).toEqual(expect.objectContaining({ + items: [], + loading: true, + query: "raio", + })) + + await vi.advanceTimersByTimeAsync(299) + + expect(mocks.searchUnifiedInWorkerProgressively).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + resolveWorkerSearch(workerResults) + await vi.runAllTicks() + + expect(mocks.reactRendererInstances[0].updateProps).toHaveBeenLastCalledWith({ + items: [ + expect.objectContaining({ + id: "spell-1", + label: "Raio de Fogo", + entityType: "Magia", + }), + ], + loading: false, + query: "raio", + }) + expect(mocks.searchUnifiedInWorkerProgressively).toHaveBeenCalledWith( + "raio", + 10, + 0, + expect.objectContaining({ specificEntityTypes: ["Magia", "Regra"] }), + expect.any(Function), + ) + expect(mocks.peekUnifiedSearch).not.toHaveBeenCalled() + }) + + it("updates the rendered mention list with partial results while keeping loading active", async () => { + const editor = { + isDestroyed: false, + commands: { blur: vi.fn(), insertContent: vi.fn() }, + state: { selection: { empty: true, from: 1 } }, + } + mocks.searchUnifiedInWorkerProgressively.mockImplementation((_query, _limit, _offset, _options, onPartial) => { + onPartial?.({ + results: [{ id: "rule-1", _id: "rule-1", name: "Raio", label: "Raio", type: "Regra", status: "active" }], + loadedProviders: 1, + totalProviders: 2, + done: false, + }) + + return Promise.resolve([ + { id: "spell-1", _id: "spell-1", name: "Raio de Fogo", label: "Raio de Fogo", type: "Magia", status: "active" }, + ]) + }) + + const { getSuggestionConfig } = await import("@/features/rules/utils/suggestion") + const config = getSuggestionConfig() + const renderer = config.render() + + const initialItems = await config.items({ query: "raio" }) + renderer.onStart?.({ + editor, + items: initialItems, + query: "raio", + text: "@raio", + range: { from: 1, to: 6 }, + command: vi.fn(), + decorationNode: null, + clientRect: () => new DOMRect(), + }) + + await vi.advanceTimersByTimeAsync(300) + await vi.runAllTicks() + + expect(mocks.reactRendererInstances[0].updateProps).toHaveBeenCalledWith({ + items: [expect.objectContaining({ id: "rule-1", entityType: "Regra" })], + loading: true, + query: "raio", + }) + expect(mocks.reactRendererInstances[0].updateProps).toHaveBeenLastCalledWith({ + items: [expect.objectContaining({ id: "spell-1", entityType: "Magia" })], + loading: false, + query: "raio", + }) + }) + + it("ignores stale worker responses from older queries", async () => { + const editor = { + isDestroyed: false, + commands: { blur: vi.fn(), insertContent: vi.fn() }, + state: { selection: { empty: true, from: 1 } }, + } + const resolvers: Array<(items: unknown[]) => void> = [] + mocks.searchUnifiedInWorkerProgressively.mockImplementation(() => new Promise((resolve) => { + resolvers.push(resolve) + })) + + const { getSuggestionConfig } = await import("@/features/rules/utils/suggestion") + const config = getSuggestionConfig() + const renderer = config.render() + + const firstItems = await config.items({ query: "rai" }) + renderer.onStart?.({ + editor, + items: firstItems, + query: "rai", + text: "@rai", + range: { from: 1, to: 5 }, + command: vi.fn(), + decorationNode: null, + clientRect: () => new DOMRect(), + }) + + await vi.advanceTimersByTimeAsync(300) + await config.items({ query: "raio" }) + await vi.advanceTimersByTimeAsync(300) + + resolvers[0]([{ id: "old", _id: "old", name: "Antigo", label: "Antigo", type: "Regra", status: "active" }]) + await vi.runAllTicks() + + expect(mocks.reactRendererInstances[0].updateProps).not.toHaveBeenCalledWith(expect.objectContaining({ + items: [expect.objectContaining({ id: "old" })], + })) + + resolvers[1]([{ id: "spell-1", _id: "spell-1", name: "Raio de Fogo", label: "Raio de Fogo", type: "Magia", status: "active" }]) + await vi.runAllTicks() + + expect(mocks.reactRendererInstances[0].updateProps).toHaveBeenLastCalledWith({ + items: [ + expect.objectContaining({ + id: "spell-1", + label: "Raio de Fogo", + entityType: "Magia", + }), + ], + loading: false, + query: "raio", + }) + }) +})