/** * Tests for ResourceContext - resource-context.tsx * * TDD RED phase: Write failing tests first. * * This includes tests for the stale registration bug identified in code review: * Resource component uses useEffect with only [name] in dependency array, * which can cause stale definitions when other props change. */ import { describe, it, expect, vi } from 'vitest' import { render, screen, act } from '@testing-library/react' import { useState, type FC } from 'react' import { ResourceProvider, useResource, ResourcesProvider, useResources, type ResourcesContextValue, } from '../../context/resource-context' import type { ResourceDefinition, Identifier, ResourceContextValue } from '../../types' // Helper component to consume single resource context function ResourceConsumer({ onResource }: { onResource: (ctx: ResourceContextValue) => void }) { const ctx = useResource() onResource(ctx) return (
{ctx.resource} {ctx.id?.toString() ?? 'none'} {ctx.hasList.toString()} {ctx.hasEdit.toString()}
) } // Helper component to consume resources collection context function ResourcesConsumer({ onResources }: { onResources: (ctx: ResourcesContextValue) => void }) { const ctx = useResources() onResources(ctx) return (
{ctx.resources.length} {ctx.resources.map((r) => r.name).join(',')}
) } const MockListComponent: FC = () =>
List
const MockEditComponent: FC<{ id: Identifier }> = ({ id }) =>
Edit {id}
describe('ResourceContext', () => { describe('ResourceProvider', () => { it('should provide resource name to children', () => { render( {}} /> ) expect(screen.getByTestId('resource').textContent).toBe('users') }) it('should provide id when viewing/editing a record', () => { render( {}} /> ) expect(screen.getByTestId('id').textContent).toBe('123') }) it('should indicate available views based on definition', () => { const definition: ResourceDefinition = { name: 'users', list: MockListComponent, edit: MockEditComponent, } render( {}} /> ) expect(screen.getByTestId('has-list').textContent).toBe('true') expect(screen.getByTestId('has-edit').textContent).toBe('true') }) it('should indicate missing views when not in definition', () => { const definition: ResourceDefinition = { name: 'users', // No list, edit, show, or create } render( {}} /> ) expect(screen.getByTestId('has-list').textContent).toBe('false') expect(screen.getByTestId('has-edit').textContent).toBe('false') }) it('should handle numeric IDs', () => { render( {}} /> ) expect(screen.getByTestId('id').textContent).toBe('42') }) it('should memoize context value when props are stable', () => { let renderCount = 0 const contextValues: ResourceContextValue[] = [] function TrackedConsumer() { const ctx = useResource() renderCount++ contextValues.push(ctx) return
render {renderCount}
} const { rerender } = render( ) rerender( ) // Context value should be the same object when props don't change expect(contextValues[0]).toBe(contextValues[1]) }) }) describe('useResource', () => { it('should throw error when used outside ResourceProvider', () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(() => { render( {}} />) }).toThrow('useResource must be used within a ResourceProvider') consoleError.mockRestore() }) }) }) describe('ResourcesContext', () => { describe('ResourcesProvider', () => { it('should provide empty resources array by default', () => { render( {}} /> ) expect(screen.getByTestId('count').textContent).toBe('0') }) it('should initialize with provided resources', () => { const resources: ResourceDefinition[] = [ { name: 'users', label: 'Users' }, { name: 'products', label: 'Products' }, ] render( {}} /> ) expect(screen.getByTestId('count').textContent).toBe('2') expect(screen.getByTestId('names').textContent).toBe('users,products') }) it('should allow registering new resources', () => { let ctx: ResourcesContextValue | undefined render( { ctx = c }} /> ) expect(screen.getByTestId('count').textContent).toBe('0') act(() => { ctx!.registerResource({ name: 'orders', label: 'Orders' }) }) // Note: This tests imperative registration - the display may not update // because ResourcesProvider uses a ref-based state pattern expect(ctx!.getResource('orders')).toBeDefined() expect(ctx!.getResource('orders')?.label).toBe('Orders') }) it('should allow unregistering resources', () => { let ctx: ResourcesContextValue | undefined const resources: ResourceDefinition[] = [ { name: 'users', label: 'Users' }, { name: 'products', label: 'Products' }, ] render( { ctx = c }} /> ) act(() => { ctx!.unregisterResource('users') }) expect(ctx!.getResource('users')).toBeUndefined() expect(ctx!.getResource('products')).toBeDefined() }) it('should allow getting a resource by name', () => { let ctx: ResourcesContextValue | undefined const resources: ResourceDefinition[] = [ { name: 'users', label: 'Users', labelPlural: 'All Users' }, ] render( { ctx = c }} /> ) const resource = ctx!.getResource('users') expect(resource?.name).toBe('users') expect(resource?.label).toBe('Users') expect(resource?.labelPlural).toBe('All Users') }) it('should return undefined for non-existent resource', () => { let ctx: ResourcesContextValue | undefined render( { ctx = c }} /> ) expect(ctx!.getResource('nonexistent')).toBeUndefined() }) it('should handle resource registration with same name (update)', () => { let ctx: ResourcesContextValue | undefined const resources: ResourceDefinition[] = [ { name: 'users', label: 'Users' }, ] render( { ctx = c }} /> ) act(() => { ctx!.registerResource({ name: 'users', label: 'Updated Users', list: MockListComponent }) }) const resource = ctx!.getResource('users') expect(resource?.label).toBe('Updated Users') expect(resource?.list).toBe(MockListComponent) }) }) describe('useResources', () => { it('should throw error when used outside ResourcesProvider', () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(() => { render( {}} />) }).toThrow('useResources must be used within a ResourcesProvider') consoleError.mockRestore() }) }) describe('stale registration bug (issue ui-n710)', () => { /** * This test demonstrates the stale registration issue identified in code review: * When a resource's view components change after initial registration, * the registered definition may become stale because useEffect only * depends on [name]. * * This is a KNOWN LIMITATION that should either be fixed or documented. */ it('should update registered definition when view components change', () => { let ctx: ResourcesContextValue | undefined // Component that dynamically changes resource definition function DynamicResource() { const [hasEdit, setHasEdit] = useState(false) const definition: ResourceDefinition = { name: 'users', label: 'Users', list: MockListComponent, ...(hasEdit ? { edit: MockEditComponent } : {}), } return ( <> { ctx = c }} /> ) } render() // Initially no edit expect(ctx!.getResource('users')?.edit).toBeUndefined() // Add edit component act(() => { screen.getByTestId('add-edit').click() }) // This test documents the expected behavior: // After changing the resource definition, getResource should return // the updated definition with the edit component. // If this fails, it means the stale registration bug exists. expect(ctx!.getResource('users')?.edit).toBe(MockEditComponent) }) }) })