frontend/tests/pages/locations.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getIconLabel } from '../../app/utils/iconLabel'
import { getLocationNameError, getLocationPhotoError, maxLocationPhotoSize } from '../../app/utils/locationValidation'
interface Location {
id: string
name: string
description?: string
parent_id?: string
icon?: string
}
interface TreeNode {
location: Location
children: TreeNode[]
level: number
}
describe('Locations Page', () => {
const mockApiFetch = vi.fn()
const mockToast = { add: vi.fn() }
const _mockRefresh = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('form accessibility and validation', () => {
it.each(['', ' ', '\n\t'])('rejects a blank location name', (name) => {
expect(getLocationNameError(name)).toBe('Location name is required')
})
it('accepts a non-blank location name', () => {
expect(getLocationNameError(' Office ')).toBeNull()
})
it('provides human-readable names for icon choices', () => {
expect(getIconLabel('i-lucide-map-pin')).toBe('Map Pin')
expect(getIconLabel('i-lucide-door-open')).toBe('Door Open')
})
})
describe('photo validation', () => {
it.each(['image/jpeg', 'image/png', 'image/gif', ''])('accepts supported photos with type %s', (type) => {
expect(getLocationPhotoError({ type, size: maxLocationPhotoSize })).toBeNull()
})
it('rejects oversized files', () => {
expect(getLocationPhotoError({ type: 'image/png', size: maxLocationPhotoSize + 1 })).toContain('10 MB')
})
it('rejects SVG and unsupported formats', () => {
expect(getLocationPhotoError({ type: 'image/svg+xml', size: 100 })).toContain('JPEG, PNG or GIF')
})
})
describe('tree building', () => {
const buildTree = (locations: Location[]): TreeNode[] => {
const childrenMap = new Map<string | undefined, Location[]>()
locations.forEach((l) => {
const parentId = l.parent_id || undefined
if (!childrenMap.has(parentId)) {
childrenMap.set(parentId, [])
}
childrenMap.get(parentId)!.push(l)
})
const buildTreeRecursive = (parentId: string | undefined, level: number): TreeNode[] => {
const children = childrenMap.get(parentId) || []
return children
.sort((a, b) => a.name.localeCompare(b.name))
.map(loc => ({
location: loc,
children: buildTreeRecursive(loc.id, level + 1),
level
}))
}
return buildTreeRecursive(undefined, 0)
}
it('builds tree from flat locations array', () => {
const locations: Location[] = [
{ id: 'home', name: 'Home', parent_id: undefined },
{ id: 'living', name: 'Living Room', parent_id: 'home' },
{ id: 'bedroom', name: 'Bedroom', parent_id: 'home' }
]
const tree = buildTree(locations)
expect(tree).toHaveLength(1)
expect(tree[0].location.name).toBe('Home')
expect(tree[0].children).toHaveLength(2)
expect(tree[0].level).toBe(0)
})
it('builds deeply nested tree', () => {
const locations: Location[] = [
{ id: 'home', name: 'Home' },
{ id: 'bedroom', name: 'Bedroom', parent_id: 'home' },
{ id: 'closet', name: 'Closet', parent_id: 'bedroom' },
{ id: 'shelf', name: 'Shelf', parent_id: 'closet' }
]
const tree = buildTree(locations)
expect(tree[0].children[0].children[0].children[0].location.name).toBe('Shelf')
expect(tree[0].children[0].children[0].children[0].level).toBe(3)
})
it('handles multiple root nodes', () => {
const locations: Location[] = [
{ id: 'home', name: 'Home' },
{ id: 'office', name: 'Office' },
{ id: 'garage', name: 'Garage' }
]
const tree = buildTree(locations)
expect(tree).toHaveLength(3)
})
it('sorts children alphabetically', () => {
const locations: Location[] = [
{ id: 'home', name: 'Home' },
{ id: 'z-room', name: 'Z Room', parent_id: 'home' },
{ id: 'a-room', name: 'A Room', parent_id: 'home' },
{ id: 'm-room', name: 'M Room', parent_id: 'home' }
]
const tree = buildTree(locations)
expect(tree[0].children[0].location.name).toBe('A Room')
expect(tree[0].children[1].location.name).toBe('M Room')
expect(tree[0].children[2].location.name).toBe('Z Room')
})
it('handles empty locations array', () => {
const tree = buildTree([])
expect(tree).toHaveLength(0)
})
})
describe('tree filtering', () => {
const filterTree = (nodes: TreeNode[], query: string): TreeNode[] => {
if (!query.trim()) return nodes
const lowerQuery = query.toLowerCase()
return nodes.reduce<TreeNode[]>((acc, node) => {
const matchesSearch = node.location.name.toLowerCase().includes(lowerQuery)
const filteredChildren = filterTree(node.children, query)
if (matchesSearch || filteredChildren.length > 0) {
acc.push({
...node,
children: filteredChildren
})
}
return acc
}, [])
}
it('filters tree by search query', () => {
const tree: TreeNode[] = [
{
location: { id: 'home', name: 'Home' },
children: [
{ location: { id: 'bedroom', name: 'Bedroom' }, children: [], level: 1 },
{ location: { id: 'kitchen', name: 'Kitchen' }, children: [], level: 1 }
],
level: 0
}
]
const filtered = filterTree(tree, 'bed')
expect(filtered).toHaveLength(1)
expect(filtered[0].children).toHaveLength(1)
expect(filtered[0].children[0].location.name).toBe('Bedroom')
})
it('returns all nodes when query is empty', () => {
const tree: TreeNode[] = [
{ location: { id: 'home', name: 'Home' }, children: [], level: 0 }
]
const filtered = filterTree(tree, '')
expect(filtered).toEqual(tree)
})
it('includes parent nodes when child matches', () => {
const tree: TreeNode[] = [
{
location: { id: 'home', name: 'Home' },
children: [
{
location: { id: 'bedroom', name: 'Bedroom' },
children: [
{ location: { id: 'closet', name: 'Walk-in Closet' }, children: [], level: 2 }
],
level: 1
}
],
level: 0
}
]
const filtered = filterTree(tree, 'closet')
expect(filtered).toHaveLength(1)
expect(filtered[0].location.name).toBe('Home')
expect(filtered[0].children[0].location.name).toBe('Bedroom')
expect(filtered[0].children[0].children[0].location.name).toBe('Walk-in Closet')
})
it('is case insensitive', () => {
const tree: TreeNode[] = [
{ location: { id: 'home', name: 'Living Room' }, children: [], level: 0 }
]
const filtered = filterTree(tree, 'LIVING')
expect(filtered).toHaveLength(1)
})
})
describe('location path (breadcrumb)', () => {
it('builds path from location to root', () => {
const locationMap = new Map<string, Location>([
['home', { id: 'home', name: 'Home' }],
['bedroom', { id: 'bedroom', name: 'Bedroom', parent_id: 'home' }],
['closet', { id: 'closet', name: 'Closet', parent_id: 'bedroom' }]
])
const getLocationPath = (location: Location): Location[] => {
const path: Location[] = []
let current: Location | undefined = location
while (current) {
path.unshift(current)
if (current.parent_id) {
current = locationMap.get(current.parent_id)
} else {
break
}
}
return path
}
const closet = locationMap.get('closet')!
const path = getLocationPath(closet)
expect(path).toHaveLength(3)
expect(path[0].name).toBe('Home')
expect(path[1].name).toBe('Bedroom')
expect(path[2].name).toBe('Closet')
})
})
describe('expand/collapse', () => {
it('toggles expanded state', () => {
const expandedNodes = new Set<string>()
const toggleExpanded = (locationId: string) => {
if (expandedNodes.has(locationId)) {
expandedNodes.delete(locationId)
} else {
expandedNodes.add(locationId)
}
}
toggleExpanded('loc-1')
expect(expandedNodes.has('loc-1')).toBe(true)
toggleExpanded('loc-1')
expect(expandedNodes.has('loc-1')).toBe(false)
})
it('expands all nodes', () => {
const locations: Location[] = [
{ id: 'loc-1', name: 'Location 1' },
{ id: 'loc-2', name: 'Location 2' },
{ id: 'loc-3', name: 'Location 3' }
]
const expandedNodes = new Set<string>()
locations.forEach(l => expandedNodes.add(l.id))
expect(expandedNodes.size).toBe(3)
})
it('collapses all nodes', () => {
const expandedNodes = new Set(['loc-1', 'loc-2', 'loc-3'])
expandedNodes.clear()
expect(expandedNodes.size).toBe(0)
})
})
describe('location icons', () => {
const getLocationIcon = (location: Location): string => {
if (location.icon) return location.icon
const name = location.name.toLowerCase()
if (name.includes('bedroom')) return 'i-lucide-bed'
if (name.includes('living')) return 'i-lucide-sofa'
if (name.includes('kitchen')) return 'i-lucide-utensils'
if (name.includes('bathroom')) return 'i-lucide-bath'
if (name.includes('garage')) return 'i-lucide-car'
if (name.includes('office')) return 'i-lucide-briefcase'
if (name.includes('closet')) return 'i-lucide-door-open'
if (name.includes('storage')) return 'i-lucide-box'
return 'i-lucide-map-pin'
}
it('returns explicit icon when provided', () => {
expect(getLocationIcon({ id: '1', name: 'Any', icon: 'i-lucide-archive' })).toBe('i-lucide-archive')
})
it('returns bed icon for bedroom', () => {
expect(getLocationIcon({ id: '1', name: 'Master Bedroom' })).toBe('i-lucide-bed')
})
it('returns sofa icon for living room', () => {
expect(getLocationIcon({ id: '1', name: 'Living Room' })).toBe('i-lucide-sofa')
})
it('returns utensils icon for kitchen', () => {
expect(getLocationIcon({ id: '1', name: 'Kitchen' })).toBe('i-lucide-utensils')
})
it('returns default icon for unknown location', () => {
expect(getLocationIcon({ id: '1', name: 'Random Space' })).toBe('i-lucide-map-pin')
})
})
describe('parent options', () => {
it('excludes current location and descendants from parent options', () => {
const locations: Location[] = [
{ id: 'home', name: 'Home' },
{ id: 'bedroom', name: 'Bedroom', parent_id: 'home' },
{ id: 'closet', name: 'Closet', parent_id: 'bedroom' },
{ id: 'office', name: 'Office' }
]
const editingLocationId = 'bedroom'
const getDescendants = (parentId: string): Set<string> => {
const descendants = new Set<string>([parentId])
const addDescendants = (id: string) => {
locations.forEach((l) => {
if (l.parent_id === id) {
descendants.add(l.id)
addDescendants(l.id)
}
})
}
addDescendants(parentId)
return descendants
}
const excludeIds = getDescendants(editingLocationId)
const availableParents = locations.filter(l => !excludeIds.has(l.id))
expect(availableParents).toHaveLength(2)
expect(availableParents.map(l => l.name)).toContain('Home')
expect(availableParents.map(l => l.name)).toContain('Office')
expect(availableParents.map(l => l.name)).not.toContain('Bedroom')
expect(availableParents.map(l => l.name)).not.toContain('Closet')
})
it('keeps hierarchy when flattening parent options', () => {
const locations: Location[] = [
{ id: 'home', name: 'Home' },
{ id: 'bedroom', name: 'Bedroom', parent_id: 'home' },
{ id: 'closet', name: 'Closet', parent_id: 'bedroom' }
]
const buildTree = (items: Location[]): TreeNode[] => {
const childrenMap = new Map<string | undefined, Location[]>()
items.forEach((l) => {
const parentId = l.parent_id || undefined
if (!childrenMap.has(parentId)) {
childrenMap.set(parentId, [])
}
childrenMap.get(parentId)!.push(l)
})
const buildTreeRecursive = (parentId: string | undefined, level: number): TreeNode[] => {
const children = childrenMap.get(parentId) || []
return children
.sort((a, b) => a.name.localeCompare(b.name))
.map(loc => ({
location: loc,
children: buildTreeRecursive(loc.id, level + 1),
level
}))
}
return buildTreeRecursive(undefined, 0)
}
const flattenOptions = (nodes: TreeNode[], depth = 0): string[] => {
return nodes.flatMap((node) => {
const label = `${depth > 0 ? `${'\u00A0'.repeat(depth * 2)}└ ` : ''}${node.location.name}`
return [label, ...flattenOptions(node.children, depth + 1)]
})
}
const labels = ['None (Top Level)', ...flattenOptions(buildTree(locations))]
expect(labels).toEqual([
'None (Top Level)',
'Home',
'\u00A0\u00A0└ Bedroom',
'\u00A0\u00A0\u00A0\u00A0└ Closet'
])
})
})
describe('CRUD operations', () => {
it('creates a new location', async () => {
const form = {
name: 'New Room',
description: 'A new room',
parent_id: undefined,
icon: 'i-lucide-archive'
}
mockApiFetch.mockResolvedValueOnce({ id: 'new-loc' })
await mockApiFetch('/api/locations', {
method: 'POST',
body: JSON.stringify(form)
})
expect(mockApiFetch).toHaveBeenCalledWith('/api/locations', {
method: 'POST',
body: JSON.stringify(form)
})
})
it('updates an existing location', async () => {
const form = {
name: 'Updated Room',
description: 'Updated description',
parent_id: 'parent-1',
icon: 'i-lucide-briefcase'
}
mockApiFetch.mockResolvedValueOnce({})
await mockApiFetch('/api/locations/loc-1', {
method: 'PUT',
body: JSON.stringify(form)
})
expect(mockApiFetch).toHaveBeenCalledWith('/api/locations/loc-1', {
method: 'PUT',
body: JSON.stringify(form)
})
})
it('prefills icon when opening edit modal', () => {
const form = {
name: '',
description: '',
parent_id: undefined as string | undefined,
icon: undefined as string | undefined
}
const location: Location = {
id: 'loc-1',
name: 'Office',
parent_id: 'root',
description: 'Main office',
icon: 'i-lucide-briefcase'
}
form.name = location.name
form.description = location.description || ''
form.parent_id = location.parent_id
form.icon = location.icon
expect(form.icon).toBe('i-lucide-briefcase')
})
it('deletes a location', async () => {
mockApiFetch.mockResolvedValueOnce({})
await mockApiFetch('/api/locations/loc-1', { method: 'DELETE' })
mockToast.add({ title: 'Location deleted', color: 'success' })
expect(mockApiFetch).toHaveBeenCalledWith('/api/locations/loc-1', { method: 'DELETE' })
expect(mockToast.add).toHaveBeenCalledWith({ title: 'Location deleted', color: 'success' })
})
it('clears selection when deleted location was selected', async () => {
let selectedLocation: Location | null = { id: 'loc-1', name: 'Test' }
const locationToDelete = { id: 'loc-1', name: 'Test' }
mockApiFetch.mockResolvedValueOnce({})
await mockApiFetch(`/api/locations/${locationToDelete.id}`, { method: 'DELETE' })
if (selectedLocation?.id === locationToDelete.id) {
selectedLocation = null
}
expect(selectedLocation).toBeNull()
})
})
describe('currency formatting', () => {
it('formats currency values', () => {
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
}
expect(formatCurrency(1000)).toBe('$1,000')
expect(formatCurrency(0)).toBe('$0')
expect(formatCurrency(1234567)).toBe('$1,234,567')
})
})
describe('total value calculation', () => {
it('calculates total value of assets', () => {
const assets = [
{ id: '1', name: 'Asset 1', purchase_price: 100 },
{ id: '2', name: 'Asset 2', purchase_price: 250 },
{ id: '3', name: 'Asset 3', purchase_price: undefined }
]
const totalValue = assets.reduce((sum, asset) => sum + (asset.purchase_price || 0), 0)
expect(totalValue).toBe(350)
})
})
describe('children lookup', () => {
it('gets children of a location', () => {
const locations: Location[] = [
{ id: 'home', name: 'Home' },
{ id: 'bedroom', name: 'Bedroom', parent_id: 'home' },
{ id: 'kitchen', name: 'Kitchen', parent_id: 'home' },
{ id: 'closet', name: 'Closet', parent_id: 'bedroom' }
]
const getChildren = (locationId: string): Location[] => {
return locations.filter(l => l.parent_id === locationId)
}
const homeChildren = getChildren('home')
expect(homeChildren).toHaveLength(2)
const bedroomChildren = getChildren('bedroom')
expect(bedroomChildren).toHaveLength(1)
})
it('checks if location has children', () => {
const locations: Location[] = [
{ id: 'home', name: 'Home' },
{ id: 'bedroom', name: 'Bedroom', parent_id: 'home' }
]
const hasChildren = (locationId: string): boolean => {
return locations.some(l => l.parent_id === locationId)
}
expect(hasChildren('home')).toBe(true)
expect(hasChildren('bedroom')).toBe(false)
})
})
})