Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 77 additions & 6 deletions packages/core/src/lib/__tests__/fuse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,31 @@ function createProject(overrides: Partial<ProjexProject> = {}): ProjexProject {
}

describe('getFuseOptions', () => {
it('should return default threshold of 0.2', () => {
it('should return default threshold of 0.3', () => {
const options = getFuseOptions()

expect(options.threshold).toBe(0.2)
expect(options.threshold).toBe(0.3)
})

it('should accept custom threshold', () => {
it('should accept custom threshold via options object', () => {
const options = getFuseOptions({ threshold: 0.5 })

expect(options.threshold).toBe(0.5)
})

it('should accept a bare threshold number (v1.3 back-compat)', () => {
const options = getFuseOptions(0.5)

expect(options.threshold).toBe(0.5)
})

it('should include name, description, and stack keys', () => {
it('should include name, tagline, description, and stack keys by default', () => {
const options = getFuseOptions()

const keyNames = options.keys.map(k => k.name)

expect(keyNames).toContain('name')
expect(keyNames).toContain('tagline')
expect(keyNames).toContain('description')
expect(keyNames).toContain('stack')
})
Expand All @@ -59,15 +66,25 @@ describe('getFuseOptions', () => {
expect(nameKey?.weight).toBe(2)
})

it('should assign higher weight to description than stack', () => {
it('should weight tagline and description equally, above stack', () => {
const options = getFuseOptions()

const taglineKey = options.keys.find(k => k.name === 'tagline')
const descKey = options.keys.find(k => k.name === 'description')
const stackKey = options.keys.find(k => k.name === 'stack')

expect(taglineKey?.weight).toBe(1.5)
expect(descKey?.weight).toBe(1.5)
expect(stackKey?.weight).toBe(1)
})

it('should use custom keys when provided via options object', () => {
const customKeys = [{ name: 'name', weight: 1 }]

const options = getFuseOptions({ keys: customKeys })

expect(options.keys).toEqual(customKeys)
})
})

describe('createFuseSearch', () => {
Expand Down Expand Up @@ -121,6 +138,19 @@ describe('createFuseSearch', () => {
expect(results[0].item.id).toBe('1')
})

it('should find projects by tagline', () => {
const projects = [
createProject({ id: '1', name: 'Project One', tagline: 'Ship your portfolio fast' }),
createProject({ id: '2', name: 'Project Two', tagline: 'A todo app for everyone' }),
]

const fuse = createFuseSearch(projects)
const results = fuse.search('portfolio')

expect(results).toHaveLength(1)
expect(results[0].item.id).toBe('1')
})

it('should find projects by stack tag', () => {
const projects = [
createProject({ id: '1', name: 'Project One', stack: ['react', 'typescript'] }),
Expand Down Expand Up @@ -150,15 +180,56 @@ describe('createFuseSearch', () => {
it('should respect custom threshold', () => {
const projects = [
createProject({ id: '1', name: 'React Dashboard' }),
createProject({ id: '2', name: 'Vue Todo' }),
]

// 'Ract' (one deletion from 'React') is within fuzzy tolerance at 0.5
// but not at 0.1, so the two thresholds must produce different sets.
const fuseStrict = createFuseSearch(projects, 0.1)
const fuseLoose = createFuseSearch(projects, 0.5)

const strictResults = fuseStrict.search('Ract')
const looseResults = fuseLoose.search('Ract')

expect(looseResults.length).toBeGreaterThanOrEqual(strictResults.length)
expect(strictResults).toHaveLength(0)
expect(looseResults).toHaveLength(1)
expect(looseResults[0].item.id).toBe('1')
expect(looseResults.length).toBeGreaterThan(strictResults.length)
})

it('should accept custom threshold via options object', () => {
const projects = [
createProject({ id: '1', name: 'React Dashboard' }),
]

const fuse = createFuseSearch(projects, { threshold: 0.4 })
const results = fuse.search('Ract')

expect(results.length).toBeGreaterThan(0)
})

it('should restrict search to custom keys via options object', () => {
const projects = [
createProject({ id: '1', name: 'Alpha', description: 'A modern dashboard' }),
createProject({ id: '2', name: 'Dashboard Tool', description: 'A simple tool' }),
]

const nameOnly = createFuseSearch(projects, { keys: [{ name: 'name', weight: 1 }] })

const results = nameOnly.search('dashboard')

expect(results).toHaveLength(1)
expect(results[0].item.id).toBe('2')
})

it('should accept a bare threshold number (v1.3 back-compat)', () => {
const projects = [
createProject({ id: '1', name: 'React Dashboard' }),
]

const fuse = createFuseSearch(projects, 0.4)

expect(fuse).toBeDefined()
})

it('should return empty array for no matches', () => {
Expand Down
129 changes: 129 additions & 0 deletions packages/core/src/lib/__tests__/searchProjects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, it, expect } from 'vitest'
import { searchProjects } from '../searchProjects'
import type { ProjexProject } from '../../types'

function createProject(overrides: Partial<ProjexProject> = {}): ProjexProject {
return {
id: 'test-id',
type: 'github',
status: 'active',
featured: false,
name: 'Test Project',
tagline: '',
description: '',
background: null,
why: null,
image: null,
struggles: [],
timeline: [],
posts: [],
stack: [],
links: {},
stats: null,
language: null,
languageColor: null,
createdAt: null,
updatedAt: null,
...overrides,
}
}

describe('searchProjects', () => {
// Vocabulary is deliberately disjoint across name, tagline, description and
// stack so each field-specific assertion can only pass via its intended field.
const projects = [
createProject({
id: '1',
name: 'Argon Grid',
tagline: 'Orchestrate your containers',
description: 'A control plane for batch scheduling',
stack: ['kafka', 'grpc'],
}),
createProject({
id: '2',
name: 'Beryl Slate',
tagline: 'Capture fleeting thoughts',
description: 'Markdown journal with backlinks',
stack: ['zig', 'sqlite'],
}),
]

it('should return the input array unchanged for an empty query', () => {
expect(searchProjects(projects, '')).toBe(projects)
})

it('should return the input array unchanged for whitespace-only query', () => {
expect(searchProjects(projects, ' ')).toBe(projects)
})

it('should return the input array unchanged for null or undefined query', () => {
expect(searchProjects(projects, null)).toBe(projects)
expect(searchProjects(projects, undefined)).toBe(projects)
})

it('should find projects by name', () => {
const results = searchProjects(projects, 'argon')

expect(results).toHaveLength(1)
expect(results[0].id).toBe('1')
})

it('should find projects by tagline', () => {
const results = searchProjects(projects, 'orchestrate')

expect(results).toHaveLength(1)
expect(results[0].id).toBe('1')
})

it('should find projects by description', () => {
const results = searchProjects(projects, 'scheduling')

expect(results).toHaveLength(1)
expect(results[0].id).toBe('1')
})

it('should find projects by stack tag', () => {
const results = searchProjects(projects, 'kafka')

expect(results).toHaveLength(1)
expect(results[0].id).toBe('1')
})

it('should restrict search to custom keys', () => {
// 'orchestrate' lives only in the tagline, so name-only keys exclude it...
const nameOnly = searchProjects(projects, 'orchestrate', {
keys: [{ name: 'name', weight: 1 }],
})
expect(nameOnly).toEqual([])

// ...and including the tagline key finds it.
const withTagline = searchProjects(projects, 'orchestrate', {
keys: [
{ name: 'name', weight: 1 },
{ name: 'tagline', weight: 1 },
],
})
expect(withTagline).toHaveLength(1)
expect(withTagline[0].id).toBe('1')
})

it('should accept a custom threshold', () => {
// 'schedulng' (one deletion from 'scheduling') is within fuzzy tolerance
// at 0.4 but not at 0.1, so the two thresholds must produce different sets.
const strict = searchProjects(projects, 'schedulng', { threshold: 0.1 })
const lenient = searchProjects(projects, 'schedulng', { threshold: 0.4 })

expect(strict).toEqual([])
expect(lenient).toHaveLength(1)
expect(lenient[0].id).toBe('1')
expect(lenient.length).toBeGreaterThan(strict.length)
})

it('should return empty array when no matches found', () => {
expect(searchProjects(projects, 'nonexistent')).toHaveLength(0)
})

it('should return empty array for empty projects', () => {
expect(searchProjects([], 'react')).toHaveLength(0)
})
})
87 changes: 84 additions & 3 deletions packages/core/src/lib/__tests__/useProjectSearch.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useProjectSearch } from '../useProjectSearch'
import { createFuseSearch } from '../fuse'
import type { ProjexProject } from '../../types'

// Wrap createFuseSearch in a spy (delegating to the real implementation) so
// index rebuilds can be counted in the memoisation tests below.
vi.mock('../fuse', async (importOriginal) => {
const actual = await importOriginal<typeof import('../fuse')>()
return { ...actual, createFuseSearch: vi.fn(actual.createFuseSearch) }
})

function createProject(overrides: Partial<ProjexProject> = {}): ProjexProject {
return {
id: 'test-id',
Expand Down Expand Up @@ -133,8 +141,81 @@ describe('useProjectSearch', () => {
})

it('should use custom threshold option', () => {
const { result } = renderHook(() => useProjectSearch(projects, 'ract', { threshold: 0.4 }))
// 'ract' (one deletion from 'React') is within fuzzy tolerance at 0.4
// but not at 0.1, so the two thresholds must produce different sets.
const { result: strict } = renderHook(() =>
useProjectSearch(projects, 'ract', { threshold: 0.1 })
)
const { result: lenient } = renderHook(() =>
useProjectSearch(projects, 'ract', { threshold: 0.4 })
)

expect(strict.current).toHaveLength(0)
expect(lenient.current).toHaveLength(1)
expect(lenient.current[0].id).toBe('1')
})

it('should find projects by tagline by default', () => {
const projectsWithTaglines = [
createProject({ id: '1', name: 'Project One', tagline: 'Show everything you ship' }),
createProject({ id: '2', name: 'Project Two', tagline: 'A calm todo list' }),
]

const { result } = renderHook(() => useProjectSearch(projectsWithTaglines, 'everything'))

expect(result.current).toHaveLength(1)
expect(result.current[0].id).toBe('1')
})

it('should restrict search to custom keys option', () => {
const { result } = renderHook(() =>
useProjectSearch(projects, 'authentication', {
keys: [{ name: 'name', weight: 1 }],
})
)

expect(result.current).toHaveLength(0)
})

it('should not rebuild the search index when inline keys have stable content', () => {
const spy = vi.mocked(createFuseSearch)
spy.mockClear()

const { rerender, result } = renderHook(
({ query }) =>
useProjectSearch(projects, query, {
keys: [{ name: 'name', weight: 2 }],
}),
{ initialProps: { query: '' } }
)

spy.mockClear()

// Each rerender passes a fresh inline keys array with identical content
rerender({ query: 'dash' })
rerender({ query: 'dashb' })
rerender({ query: 'dashboard' })

expect(spy).not.toHaveBeenCalled()
expect(result.current).toHaveLength(1)
expect(result.current[0].id).toBe('1')
})

it('should rebuild the search index when key content changes', () => {
const spy = vi.mocked(createFuseSearch)
spy.mockClear()

const { rerender } = renderHook(
({ keys }) => useProjectSearch(projects, 'authentication', { keys }),
{ initialProps: { keys: [{ name: 'name', weight: 1 }] } }
)

spy.mockClear()

rerender({ keys: [{ name: 'name', weight: 1 }] })
expect(spy).not.toHaveBeenCalled()

expect(result.current.length).toBeGreaterThan(0)
rerender({ keys: [{ name: 'description', weight: 1 }] })
expect(spy).toHaveBeenCalledTimes(1)
})
})
Loading